Compare commits

...

16 Commits

Author SHA1 Message Date
Horoli 9540dd22a3 update 2025-12-08 03:00:36 +09:00
Horoli 135bf26332 Docs: Consolidate prompts 61-76 into summary and update context 2025-12-07 22:06:51 +09:00
Horoli 59ebb7cd98 Fix: Correct item name interpolation 2025-12-07 21:47:51 +09:00
Horoli e1b000772e Fix: Prevent enemy preemptive defense on first turn 2025-12-07 21:36:40 +09:00
Horoli fef803d064 Fix: Delay Enemy Intent generation to allow attack animation to finish 2025-12-07 21:28:33 +09:00
Horoli 0b48aea16d Fix: Revert buffer delay, unify player/enemy logic 2025-12-07 19:50:56 +09:00
Horoli cb7e75064f Fix: Remove redundant animation wrapper for enemy 2025-12-07 19:45:16 +09:00
Horoli 3030c09d55 Fix: Add buffer delay to Enemy Turn for animation sync 2025-12-07 19:37:17 +09:00
Horoli 1720725826 Fix: Sync Enemy Attack Animation with Damage Calculation 2025-12-07 19:25:26 +09:00
Horoli e6facc41f8 Feat: Add SettingsProvider and Toggle for Enemy Animations 2025-12-07 19:20:43 +09:00
Horoli 78267b7e47 Feat: Implement Enemy Attack Animation 2025-12-07 19:18:40 +09:00
Horoli 9a7498af35 Docs: Update project context with i18n convention 2025-12-07 19:10:03 +09:00
Horoli c570d61563 Refactor: Introduce AppStrings for soft i18n 2025-12-07 18:58:44 +09:00
Horoli 9a9022356a Refactor: Centralize Constants and Configuration 2025-12-07 18:49:07 +09:00
Horoli 32c77dd20a Refactor Item Creation: Introduce LootGenerator 2025-12-07 18:47:36 +09:00
Horoli 46658b22c8 Refactor BattleProvider: Introduce CombatCalculator and BattleLogManager 2025-12-07 18:45:33 +09:00
27 changed files with 1589 additions and 817 deletions

View File

@ -0,0 +1,65 @@
class AppStrings {
// Main Menu
static const String gameTitle = "Colosseum's Choice";
static const String startGame = "Start Game";
static const String continueGame = "Continue";
static const String exitGame = "Exit Game";
static const String credits = "Credits";
// Common Actions
static const String confirm = "Confirm";
static const String cancel = "Cancel";
static const String back = "Back";
static const String close = "Close";
static const String equip = "Equip";
static const String unequip = "Unequip";
static const String discard = "Discard";
static const String sell = "Sell";
static const String buy = "Buy";
// Stats
static const String hp = "HP";
static const String atk = "ATK";
static const String def = "DEF";
static const String armor = "Armor";
static const String luck = "Luck";
static const String gold = "Gold";
// Battle
static const String attack = "Attack";
static const String defend = "Defend";
static const String turn = "Turn";
static const String playerTurn = "Player's Turn";
static const String enemyTurn = "Enemy's Turn";
static const String victory = "Victory!";
static const String defeat = "Defeat";
static const String reward = "Reward";
static const String chooseReward = "Choose a Reward";
static const String skip = "Skip";
static const String nextStage = "Next Stage";
static const String returnToMenu = "Return to Menu";
static const String restart = "Restart";
// Inventory
static const String inventory = "Inventory";
static const String equipment = "Equipment";
static const String bag = "Bag";
static const String emptySlot = "Empty";
static const String noItems = "No items in inventory";
// Shop
static const String shopTitle = "Merchant";
static const String shopWelcome = "Welcome, traveler!";
static const String refreshShop = "Restock";
static const String notEnoughGold = "Not enough gold!";
static const String inventoryFull = "Inventory is full!";
// Risk Levels
static const String riskSafe = "Safe";
static const String riskNormal = "Normal";
static const String riskRisky = "Risky";
// Settings
static const String settings = "Settings";
static const String enemyAnimations = "Enemy Animations";
}

View File

@ -20,6 +20,20 @@ class BattleConfig {
static const double sizeNormal = 60.0;
static const double sizeSafe = 40.0;
// Logic Constants
// Safe
static const double safeBaseChance = 1.0; // 100%
static const double safeEfficiency = 0.5; // 50%
// Normal
static const double normalBaseChance = 0.8; // 80%
static const double normalEfficiency = 1.0; // 100%
// Risky
static const double riskyBaseChance = 0.4; // 40%
static const double riskyEfficiency = 2.0; // 200%
// Enemy Logic
static const double enemyAttackChance = 0.7; // 70% Attack, 30% Defend
static IconData getIcon(ActionType type) {
switch (type) {
case ActionType.attack:

View File

@ -8,7 +8,7 @@ class GameConfig {
static const int shopRerollCost = 50;
// Stages
static const int eliteStageInterval = 10;
static const int eliteStageInterval = 12;
static const int shopStageInterval = 5;
static const int restStageInterval = 8;
static const int tier1StageMax = 12;
@ -19,10 +19,15 @@ class GameConfig {
static const double vulnerableDamageMultiplier = 1.5;
static const double armorDecayRate = 0.5;
// Rewards
static const int baseGoldReward = 10;
static const int goldRewardPerStage = 5;
static const int goldRewardVariance = 10;
// Animations (Duration in milliseconds)
static const int animDelaySafe = 500;
static const int animDelayNormal = 400;
static const int animDelayRisky = 1100;
static const int animDelaySafe = 600; // 500 + 100 buffer
static const int animDelayNormal = 500; // 400 + 100 buffer
static const int animDelayRisky = 1200; // 1100 + 100 buffer
static const int animDelayEnemyTurn = 1000;
// Save System

View File

@ -11,4 +11,7 @@ class ItemConfig {
ItemRarity.legendary: 4,
ItemRarity.unique: 1,
};
// Loot Generation
static const double magicPrefixChance = 0.5; // 50%
}

View File

@ -4,8 +4,9 @@ import 'package:flutter/services.dart';
import '../model/item.dart';
import '../enums.dart';
import '../config/item_config.dart';
import 'item_prefix_table.dart'; // Import prefix table
import 'name_generator.dart'; // Import name generator
// import 'item_prefix_table.dart'; // Logic moved to LootGenerator
// import 'name_generator.dart'; // Logic moved to LootGenerator
import '../logic/loot_generator.dart'; // Import LootGenerator
import '../../utils/game_math.dart';
class ItemTemplate {
@ -69,122 +70,9 @@ class ItemTemplate {
}
Item createItem({int stage = 1}) {
// Stage-based scaling is removed.
// Apply Prefix Logic based on Rarity.
String finalName = name;
int finalAtk = atkBonus;
int finalHp = hpBonus;
int finalArmor = armorBonus;
int finalLuck = luck;
final random = Random();
// 0. Normal Rarity: Prefix logic for base stat variations
if (rarity == ItemRarity.normal) {
// Weighted Random Selection
final prefixes = ItemPrefixTable.normalPrefixes;
int totalWeight = prefixes.fold(0, (sum, p) => sum + p.weight);
int roll = random.nextInt(totalWeight);
ItemModifier? selectedModifier;
int currentSum = 0;
for (var mod in prefixes) {
currentSum += mod.weight;
if (roll < currentSum) {
selectedModifier = mod;
break;
}
}
if (selectedModifier != null) {
if (selectedModifier.prefix.isNotEmpty) {
finalName = "${selectedModifier.prefix} $name";
}
double mult = selectedModifier.multiplier;
if (mult != 1.0) {
finalAtk = (finalAtk * mult).floor();
finalHp = (finalHp * mult).floor();
finalArmor = (finalArmor * mult).floor();
// Luck usually isn't scaled by small multipliers, but let's keep it consistent or skip.
// Skipping luck scaling for normal prefixes to avoid 0.
}
}
}
// 1. Magic Rarity: 50% chance to get a Magic Prefix (1 stat change)
else if (rarity == ItemRarity.magic) {
if (random.nextBool()) { // 50% chance
// Filter valid prefixes for this slot
final validPrefixes = ItemPrefixTable.magicPrefixes.where((p) {
return p.allowedSlots == null || p.allowedSlots!.contains(slot);
}).toList();
if (validPrefixes.isNotEmpty) {
final modifier = validPrefixes[random.nextInt(validPrefixes.length)];
finalName = "${modifier.prefix} $name";
modifier.statChanges.forEach((stat, value) {
switch(stat) {
case StatType.atk: finalAtk += value; break;
case StatType.maxHp: finalHp += value; break;
case StatType.defense: finalArmor += value; break;
case StatType.luck: finalLuck += value; break;
}
});
}
}
}
// 2. Rare Rarity: Always get a Rare Prefix (2 stat changes or stronger)
else if (rarity == ItemRarity.rare) {
bool nameChanged = false;
// Always generate a completely new cool name for Rare items
finalName = NameGenerator.generateName(slot);
nameChanged = true;
// Filter valid prefixes for this slot
final validPrefixes = ItemPrefixTable.rarePrefixes.where((p) {
return p.allowedSlots == null || p.allowedSlots!.contains(slot);
}).toList();
if (validPrefixes.isNotEmpty) {
final modifier = validPrefixes[random.nextInt(validPrefixes.length)];
// If name wasn't already changed by NameGenerator, apply prefix to name
if (!nameChanged) {
finalName = "${modifier.prefix} $name";
}
// Even if name changed, we STILL apply the stats from the prefix modifier!
// Because NameGenerator is just visual flavor, stats come from the modifier.
modifier.statChanges.forEach((stat, value) {
switch(stat) {
case StatType.atk: finalAtk += value; break;
case StatType.maxHp: finalHp += value; break;
case StatType.defense: finalArmor += value; break;
case StatType.luck: finalLuck += value; break;
}
});
}
}
// Legendary/Unique items usually keep their original names/stats as they are special.
return Item(
id: id,
name: finalName,
description: description,
atkBonus: finalAtk,
hpBonus: finalHp,
armorBonus: finalArmor,
slot: slot,
effects: effects,
price: price,
image: image,
luck: finalLuck,
rarity: rarity,
tier: tier,
);
// Stage parameter kept for interface compatibility but unused here,
// as scaling is now handled via Tier/Rarity in LootGenerator/Table logic.
return LootGenerator.generate(this);
}
}

View File

@ -0,0 +1,16 @@
import 'package:flutter/foundation.dart';
class BattleLogManager {
final List<String> _logs = [];
List<String> get logs => List.unmodifiable(_logs);
void addLog(String message) {
_logs.add(message);
debugPrint("[BattleLog] $message"); // Optional: Console logging for debug
}
void clear() {
_logs.clear();
}
}

View File

@ -0,0 +1,160 @@
import 'dart:math';
import '../model/entity.dart';
import '../model/status_effect.dart';
import '../enums.dart';
import '../config/game_config.dart';
import '../config/battle_config.dart'; // Import BattleConfig
import '../model/damage_event.dart';
class CombatResult {
final bool success;
final int value;
final double efficiency;
final bool isCritical; // Future extension
CombatResult({
required this.success,
required this.value,
required this.efficiency,
this.isCritical = false,
});
}
class CombatCalculator {
static final Random _random = Random();
/// Calculates success and efficiency based on Risk Level and Luck.
static CombatResult calculateActionOutcome({
required RiskLevel risk,
required int luck,
required int baseValue,
}) {
double efficiency = 1.0;
double baseChance = 0.0;
switch (risk) {
case RiskLevel.safe:
baseChance = BattleConfig.safeBaseChance;
efficiency = BattleConfig.safeEfficiency;
break;
case RiskLevel.normal:
baseChance = BattleConfig.normalBaseChance;
efficiency = BattleConfig.normalEfficiency;
break;
case RiskLevel.risky:
baseChance = BattleConfig.riskyBaseChance;
efficiency = BattleConfig.riskyEfficiency;
break;
}
// Apply Luck (1 Luck = +1%)
double chance = baseChance + (luck / 100.0);
if (chance > 1.0) chance = 1.0;
bool success = _random.nextDouble() < chance;
int finalValue = (baseValue * efficiency).toInt();
if (finalValue < 1 && baseValue > 0) finalValue = 1;
return CombatResult(
success: success,
value: finalValue,
efficiency: efficiency,
);
}
/// Calculates actual damage to HP after applying armor and vulnerability.
static int calculateDamageToHp({
required int incomingDamage,
required int currentArmor,
required bool isVulnerable,
}) {
int damage = incomingDamage;
// 1. Vulnerability check
if (isVulnerable) {
damage = (damage * GameConfig.vulnerableDamageMultiplier).toInt();
}
// 2. Armor absorption
int damageToHp = 0;
if (currentArmor > 0) {
if (currentArmor >= damage) {
// Fully absorbed
damageToHp = 0;
} else {
damageToHp = damage - currentArmor;
}
} else {
damageToHp = damage;
}
return damageToHp;
}
/// Calculates armor remaining after damage absorption.
static int calculateRemainingArmor({
required int incomingDamage,
required int currentArmor,
required bool isVulnerable,
}) {
int damage = incomingDamage;
if (isVulnerable) {
damage = (damage * GameConfig.vulnerableDamageMultiplier).toInt();
}
if (currentArmor > 0) {
if (currentArmor >= damage) {
return currentArmor - damage;
} else {
return 0;
}
}
return 0;
}
/// Checks if status effects (Bleed, Stun) allow action and returns bleed damage.
static Map<String, dynamic> processStartTurnEffects(Character character) {
int totalBleedDamage = 0;
bool isStunned = false;
// 1. Bleed Damage
var bleedEffects = character.statusEffects
.where((e) => e.type == StatusEffectType.bleed)
.toList();
if (bleedEffects.isNotEmpty) {
totalBleedDamage = bleedEffects.fold(0, (sum, e) => sum + e.value);
}
// 2. Stun Check
if (character.hasStatus(StatusEffectType.stun)) {
isStunned = true;
}
return {
'bleedDamage': totalBleedDamage,
'isStunned': isStunned,
};
}
/// Tries to apply status effects from attacker's equipment.
/// Returns a list of applied effects.
static List<StatusEffect> getAppliedEffects(Character attacker) {
List<StatusEffect> appliedEffects = [];
for (var item in attacker.equipment.values) {
for (var effect in item.effects) {
if (_random.nextInt(100) < effect.probability) {
appliedEffects.add(
StatusEffect(
type: effect.type,
duration: effect.duration,
value: effect.value,
),
);
}
}
}
return appliedEffects;
}
}

View File

@ -0,0 +1,142 @@
import 'dart:math';
import '../model/item.dart';
import '../data/item_table.dart'; // For ItemTemplate
import '../data/item_prefix_table.dart';
import '../data/name_generator.dart';
import '../enums.dart';
import '../config/item_config.dart'; // Import ItemConfig
class LootGenerator {
static final Random _random = Random();
/// Generates an Item instance from a template, applying prefixes/suffixes based on rarity.
static Item generate(ItemTemplate template) {
String finalName = template.name;
int finalAtk = template.atkBonus;
int finalHp = template.hpBonus;
int finalArmor = template.armorBonus;
int finalLuck = template.luck;
// 0. Normal Rarity: Prefix logic for base stat variations
if (template.rarity == ItemRarity.normal) {
// Weighted Random Selection
final prefixes = ItemPrefixTable.normalPrefixes;
int totalWeight = prefixes.fold(0, (sum, p) => sum + p.weight);
int roll = _random.nextInt(totalWeight);
ItemModifier? selectedModifier;
int currentSum = 0;
for (var mod in prefixes) {
currentSum += mod.weight;
if (roll < currentSum) {
selectedModifier = mod;
break;
}
}
if (selectedModifier != null) {
if (selectedModifier.prefix.isNotEmpty) {
finalName = "${selectedModifier.prefix} ${template.name}";
}
double mult = selectedModifier.multiplier;
if (mult != 1.0) {
finalAtk = (finalAtk * mult).floor();
finalHp = (finalHp * mult).floor();
finalArmor = (finalArmor * mult).floor();
}
}
}
// 1. Magic Rarity: 50% chance to get a Magic Prefix (1 stat change)
else if (template.rarity == ItemRarity.magic) {
if (_random.nextDouble() < ItemConfig.magicPrefixChance) {
// Use constant
// Filter valid prefixes for this slot
final validPrefixes = ItemPrefixTable.magicPrefixes.where((p) {
return p.allowedSlots == null ||
p.allowedSlots!.contains(template.slot);
}).toList();
if (validPrefixes.isNotEmpty) {
final modifier = validPrefixes[_random.nextInt(validPrefixes.length)];
finalName = "${modifier.prefix} ${template.name}";
modifier.statChanges.forEach((stat, value) {
switch (stat) {
case StatType.atk:
finalAtk += value;
break;
case StatType.maxHp:
finalHp += value;
break;
case StatType.defense:
finalArmor += value;
break;
case StatType.luck:
finalLuck += value;
break;
}
});
}
}
}
// 2. Rare Rarity: Always get a Rare Prefix (2 stat changes or stronger)
else if (template.rarity == ItemRarity.rare) {
bool nameChanged = false;
// Always generate a completely new cool name for Rare items
finalName = NameGenerator.generateName(template.slot);
nameChanged = true;
// Filter valid prefixes for this slot
final validPrefixes = ItemPrefixTable.rarePrefixes.where((p) {
return p.allowedSlots == null ||
p.allowedSlots!.contains(template.slot);
}).toList();
if (validPrefixes.isNotEmpty) {
final modifier = validPrefixes[_random.nextInt(validPrefixes.length)];
// If name wasn't already changed by NameGenerator, apply prefix to name
if (!nameChanged) {
finalName = "${modifier.prefix} ${template.name}";
}
// Even if name changed, we STILL apply the stats from the prefix modifier!
modifier.statChanges.forEach((stat, value) {
switch (stat) {
case StatType.atk:
finalAtk += value;
break;
case StatType.maxHp:
finalHp += value;
break;
case StatType.defense:
finalArmor += value;
break;
case StatType.luck:
finalLuck += value;
break;
}
});
}
}
// Legendary/Unique items usually keep their original names/stats.
return Item(
id: template.id,
name: finalName,
description: template.description,
atkBonus: finalAtk,
hpBonus: finalHp,
armorBonus: finalArmor,
slot: template.slot,
effects: template.effects,
price: template.price,
image: template.image,
luck: finalLuck,
rarity: template.rarity,
tier: template.tier,
);
}
}

View File

@ -1,4 +1,5 @@
import '../enums.dart';
import 'entity.dart'; // Import Character entity
enum EffectTarget { player, enemy }
@ -9,11 +10,25 @@ class EffectEvent {
final EffectTarget target; //
final BattleFeedbackType? feedbackType; //
// New fields for impact logic
final Character? attacker;
final Character? targetEntity; //
final int? damageValue; //
final bool? isSuccess; // (Missed or Failed가 )
final int? armorGained; //
final bool triggersTurnChange; //
EffectEvent({
required this.id,
required this.type,
required this.risk,
required this.target,
this.feedbackType, // feedbackType
this.attacker,
this.targetEntity,
this.damageValue,
this.isSuccess,
this.armorGained,
this.triggersTurnChange = true,
});
}

View File

@ -48,9 +48,7 @@ class Character {
'baseDefense': baseDefense,
'gold': gold,
'image': image,
'equipment': equipment.map(
(key, value) => MapEntry(key.name, value.id),
),
'equipment': equipment.map((key, value) => MapEntry(key.name, value.id)),
'inventory': inventory.map((e) => e.id).toList(),
'statusEffects': statusEffects.map((e) => e.toJson()).toList(),
'permanentModifiers': permanentModifiers.map((e) => e.toJson()).toList(),

View File

@ -5,6 +5,7 @@ import 'game/data/enemy_table.dart';
import 'game/data/player_table.dart';
import 'providers/battle_provider.dart';
import 'providers/shop_provider.dart'; // Import ShopProvider
import 'providers/settings_provider.dart'; // Import SettingsProvider
import 'screens/main_menu_screen.dart';
void main() async {
@ -22,6 +23,7 @@ class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => SettingsProvider()), // Register SettingsProvider
ChangeNotifierProvider(create: (_) => ShopProvider()),
ChangeNotifierProxyProvider<ShopProvider, BattleProvider>(
create: (context) => BattleProvider(

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class SettingsProvider with ChangeNotifier {
static const String _keyEnemyAnim = 'settings_enemy_anim';
bool _enableEnemyAnimations = false; // Default: Disabled
bool get enableEnemyAnimations => _enableEnemyAnimations;
SettingsProvider() {
_loadSettings();
}
Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
_enableEnemyAnimations = prefs.getBool(_keyEnemyAnim) ?? false;
notifyListeners();
}
Future<void> toggleEnemyAnimations(bool value) async {
_enableEnemyAnimations = value;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyEnemyAnim, value);
}
}

View File

@ -21,6 +21,8 @@ import '../widgets/battle/explosion_widget.dart';
import 'main_menu_screen.dart';
import '../game/config/battle_config.dart';
import '../game/config/theme_config.dart';
import '../game/config/app_strings.dart';
import '../providers/settings_provider.dart'; // Import SettingsProvider
class BattleScreen extends StatefulWidget {
const BattleScreen({super.key});
@ -41,14 +43,19 @@ class _BattleScreenState extends State<BattleScreen> {
final GlobalKey<ShakeWidgetState> _shakeKey = GlobalKey<ShakeWidgetState>();
final GlobalKey<BattleAnimationWidgetState> _playerAnimKey =
GlobalKey<BattleAnimationWidgetState>();
final GlobalKey<BattleAnimationWidgetState> _enemyAnimKey =
GlobalKey<BattleAnimationWidgetState>(); // Added Enemy Anim Key
final GlobalKey<ExplosionWidgetState> _explosionKey =
GlobalKey<ExplosionWidgetState>();
bool _showLogs = true;
bool _showLogs = false;
bool _isPlayerAttacking = false; // Player Attack Animation State
bool _isEnemyAttacking = false; // Enemy Attack Animation State
DateTime? _lastFeedbackTime; // Cooldown to prevent duplicate feedback texts
@override
void initState() {
super.initState();
print("[UI Debug] BattleScreen initialized: ${hashCode}"); // Debug Log
final battleProvider = context.read<BattleProvider>();
_damageSubscription = battleProvider.damageStream.listen(
_addFloatingDamageText,
@ -66,7 +73,6 @@ class _BattleScreenState extends State<BattleScreen> {
}
void _addFloatingDamageText(DamageEvent event) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
GlobalKey targetKey = event.target == DamageTarget.player
@ -114,7 +120,6 @@ class _BattleScreenState extends State<BattleScreen> {
),
);
});
});
}
final Set<String> _processedEffectIds = {};
@ -123,14 +128,24 @@ class _BattleScreenState extends State<BattleScreen> {
if (_processedEffectIds.contains(event.id)) {
return;
}
_processedEffectIds.add(event.id);
if (_processedEffectIds.length > 20) {
_processedEffectIds.remove(_processedEffectIds.first);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// Feedback Text Cooldown
if (event.feedbackType != null) {
print("[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}");
if (_lastFeedbackTime != null &&
DateTime.now().difference(_lastFeedbackTime!).inMilliseconds < 300) {
return; // Skip if too soon
}
_lastFeedbackTime = DateTime.now();
}
GlobalKey targetKey = event.target == EffectTarget.player
? _playerKey
: _enemyKey;
@ -157,7 +172,7 @@ class _BattleScreenState extends State<BattleScreen> {
void showEffect() {
if (!mounted) return;
// feedbackType이
// Handle Feedback Text (MISS / FAILED)
if (event.feedbackType != null) {
String feedbackText;
Color feedbackColor;
@ -171,21 +186,29 @@ class _BattleScreenState extends State<BattleScreen> {
feedbackColor = ThemeConfig.failedText;
break;
default:
feedbackText = ""; // Should not happen with current enums
feedbackText = "";
feedbackColor = ThemeConfig.textColorWhite;
}
final String id = UniqueKey().toString();
// Prevent duplicate feedback texts for the same event ID (UI Level)
if (_floatingFeedbackTexts.any((e) => e.eventId == event.id)) {
return;
}
setState(() {
_floatingFeedbackTexts.clear(); // Clear previous texts
_floatingFeedbackTexts.add(
FeedbackTextData(
id: id,
eventId: event.id,
widget: Positioned(
left: position.dx,
top: position.dy,
child: FloatingFeedbackText(
key: ValueKey(id),
feedback: feedbackText,
feedback: "$feedbackText (${event.id.substring(0, 4)})",
color: feedbackColor,
onRemove: () {
if (mounted) {
@ -199,10 +222,10 @@ class _BattleScreenState extends State<BattleScreen> {
),
);
});
return; // feedbackType이
return; // Return early for feedback
}
// Use BattleConfig for Icon, Color, and Size
// Handle Icon Effect
IconData icon = BattleConfig.getIcon(event.type);
Color color = BattleConfig.getColor(event.type, event.risk);
double size = BattleConfig.getSize(event.risk);
@ -235,11 +258,10 @@ class _BattleScreenState extends State<BattleScreen> {
});
}
// 1. Attack Animation Trigger (All Risk Levels)
// 1. Player Attack Animation Trigger (Success or Miss)
if (event.type == ActionType.attack &&
event.target == EffectTarget.enemy &&
event.feedbackType == null) {
// Calculate target position (Enemy) relative to Player
event.target == EffectTarget.enemy) {
final RenderBox? playerBox =
_playerKey.currentContext?.findRenderObject() as RenderBox?;
final RenderBox? enemyBox =
@ -248,27 +270,26 @@ class _BattleScreenState extends State<BattleScreen> {
if (playerBox != null && enemyBox != null) {
final playerPos = playerBox.localToGlobal(Offset.zero);
final enemyPos = enemyBox.localToGlobal(Offset.zero);
final offset = enemyPos - playerPos;
// Start Animation: Hide Stats
setState(() {
_isPlayerAttacking = true;
});
// Force SAFE animation for MISS, otherwise use event risk
final RiskLevel animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk;
_playerAnimKey.currentState
?.animateAttack(offset, () {
showEffect(); // Show Effect at Impact!
// Shake and Explosion ONLY for Risky
if (event.risk == RiskLevel.risky) {
_shakeKey.currentState?.shake();
showEffect();
context.read<BattleProvider>().handleImpact(event);
if (event.risk == RiskLevel.risky && event.feedbackType == null) {
_shakeKey.currentState?.shake();
RenderBox? stackBox =
_stackKey.currentContext?.findRenderObject()
as RenderBox?;
_stackKey.currentContext?.findRenderObject() as RenderBox?;
if (stackBox != null) {
Offset localEnemyPos = stackBox.globalToLocal(enemyPos);
// Center of the enemy card roughly
localEnemyPos += Offset(
enemyBox.size.width / 2,
enemyBox.size.height / 2,
@ -276,9 +297,8 @@ class _BattleScreenState extends State<BattleScreen> {
_explosionKey.currentState?.explode(localEnemyPos);
}
}
}, event.risk)
}, animRisk)
.then((_) {
// End Animation: Show Stats
if (mounted) {
setState(() {
_isPlayerAttacking = false;
@ -286,12 +306,96 @@ class _BattleScreenState extends State<BattleScreen> {
}
});
}
} else {
// Not a player attack, show immediately
}
// 2. Enemy Attack Animation Trigger (Success or Miss)
else if (event.type == ActionType.attack &&
event.target == EffectTarget.player) {
bool enableAnim = context.read<SettingsProvider>().enableEnemyAnimations;
if (!enableAnim) {
showEffect();
context.read<BattleProvider>().handleImpact(event);
return;
}
final RenderBox? playerBox =
_playerKey.currentContext?.findRenderObject() as RenderBox?;
final RenderBox? enemyBox =
_enemyKey.currentContext?.findRenderObject() as RenderBox?;
if (playerBox != null && enemyBox != null) {
final playerPos = playerBox.localToGlobal(Offset.zero);
final enemyPos = enemyBox.localToGlobal(Offset.zero);
final offset = playerPos - enemyPos;
setState(() {
_isEnemyAttacking = true;
});
// Force SAFE animation for MISS
final RiskLevel animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk;
_enemyAnimKey.currentState
?.animateAttack(offset, () {
showEffect();
context.read<BattleProvider>().handleImpact(event);
if (event.risk == RiskLevel.risky && event.feedbackType == null) {
_shakeKey.currentState?.shake();
RenderBox? stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?;
if (stackBox != null) {
Offset localPlayerPos = stackBox.globalToLocal(playerPos);
localPlayerPos += Offset(
playerBox.size.width / 2,
playerBox.size.height / 2,
);
_explosionKey.currentState?.explode(localPlayerPos);
}
}
}, animRisk)
.then((_) {
if (mounted) {
setState(() {
_isEnemyAttacking = false;
});
}
});
}
}
// 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);
});
} else if (event.target == EffectTarget.enemy) {
_enemyAnimKey.currentState?.animateDefense(() {
showEffect();
context.read<BattleProvider>().handleImpact(event);
});
} else {
showEffect();
context.read<BattleProvider>().handleImpact(event);
}
}
// 4. Others (Feedback for attacks, Buffs, etc.)
else {
showEffect();
// If it's a feedback event (MISS/FAILED for attacks), wait 500ms.
if (event.feedbackType != null) {
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) context.read<BattleProvider>().handleImpact(event);
});
} else {
// Success events (Icon)
context.read<BattleProvider>().handleImpact(event);
}
}
}
void _showRiskLevelSelection(BuildContext context, ActionType actionType) {
final player = context.read<BattleProvider>().player;
@ -378,6 +482,11 @@ class _BattleScreenState extends State<BattleScreen> {
@override
Widget build(BuildContext context) {
// Sync animation setting to provider logic
final settings = context.watch<SettingsProvider>();
context.read<BattleProvider>().skipAnimations =
!settings.enableEnemyAnimations;
return ResponsiveContainer(
child: Consumer<BattleProvider>(
builder: (context, battleProvider, child) {
@ -421,7 +530,7 @@ class _BattleScreenState extends State<BattleScreen> {
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
"Turn ${battleProvider.turnCount}",
"${AppStrings.turn} ${battleProvider.turnCount}",
style: const TextStyle(
color: ThemeConfig.textColorWhite,
fontSize: ThemeConfig.fontSizeHeader,
@ -448,6 +557,8 @@ class _BattleScreenState extends State<BattleScreen> {
isPlayer: false,
isTurn: !battleProvider.isPlayerTurn,
key: _enemyKey,
animationKey: _enemyAnimKey, // Direct Pass
hideStats: _isEnemyAttacking,
),
),
// Player (Bottom Left)
@ -540,12 +651,18 @@ class _BattleScreenState extends State<BattleScreen> {
child: SimpleDialog(
title: Row(
children: [
const Text("Victory! Choose a Reward"),
const Text(
"${AppStrings.victory} ${AppStrings.chooseReward}",
),
const Spacer(),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.monetization_on, color: ThemeConfig.statGoldColor, size: 18),
Icon(
Icons.monetization_on,
color: ThemeConfig.statGoldColor,
size: 18,
),
const SizedBox(width: 4),
Text(
"${battleProvider.lastGoldReward} G",
@ -568,7 +685,7 @@ class _BattleScreenState extends State<BattleScreen> {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Inventory is full! Cannot take item.",
"${AppStrings.inventoryFull} Cannot take item.",
),
backgroundColor: Colors.red,
),
@ -586,10 +703,11 @@ class _BattleScreenState extends State<BattleScreen> {
decoration: BoxDecoration(
color: Colors.blueGrey[700],
borderRadius: BorderRadius.circular(
4),
4,
),
border: Border.all(
color: item.rarity !=
ItemRarity.magic
color:
item.rarity != ItemRarity.magic
? ItemUtils.getRarityColor(
item.rarity,
)
@ -613,7 +731,8 @@ class _BattleScreenState extends State<BattleScreen> {
color: isSkip
? ThemeConfig.textColorGrey
: ItemUtils.getRarityColor(
item.rarity),
item.rarity,
),
),
),
],
@ -651,7 +770,7 @@ class _BattleScreenState extends State<BattleScreen> {
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"DEFEAT",
AppStrings.defeat,
style: TextStyle(
color: ThemeConfig.statHpColor,
fontSize: ThemeConfig.fontSizeHuge,
@ -677,7 +796,7 @@ class _BattleScreenState extends State<BattleScreen> {
);
},
child: const Text(
"Return to Main Menu",
AppStrings.returnToMenu,
style: TextStyle(
color: ThemeConfig.textColorWhite,
fontSize: ThemeConfig.fontSizeHeader,
@ -698,10 +817,10 @@ class _BattleScreenState extends State<BattleScreen> {
Widget _buildItemStatText(Item item) {
List<String> stats = [];
if (item.atkBonus > 0) stats.add("+${item.atkBonus} ATK");
if (item.hpBonus > 0) stats.add("+${item.hpBonus} HP");
if (item.armorBonus > 0) stats.add("+${item.armorBonus} DEF");
if (item.luck > 0) stats.add("+${item.luck} Luck");
if (item.atkBonus > 0) stats.add("+${item.atkBonus} ${AppStrings.atk}");
if (item.hpBonus > 0) stats.add("+${item.hpBonus} ${AppStrings.hp}");
if (item.armorBonus > 0) stats.add("+${item.armorBonus} ${AppStrings.def}");
if (item.luck > 0) stats.add("+${item.luck} ${AppStrings.luck}");
List<String> effectTexts = item.effects.map((e) => e.description).toList();
@ -715,7 +834,10 @@ class _BattleScreenState extends State<BattleScreen> {
padding: const EdgeInsets.only(top: 4.0, bottom: 4.0),
child: Text(
stats.join(", "),
style: const TextStyle(fontSize: ThemeConfig.fontSizeMedium, color: ThemeConfig.statAtkColor),
style: const TextStyle(
fontSize: ThemeConfig.fontSizeMedium,
color: ThemeConfig.statAtkColor,
),
),
),
if (effectTexts.isNotEmpty)
@ -723,7 +845,10 @@ class _BattleScreenState extends State<BattleScreen> {
padding: const EdgeInsets.only(bottom: 4.0),
child: Text(
effectTexts.join(", "),
style: const TextStyle(fontSize: 11, color: ThemeConfig.rarityLegendary), // 11 is custom, keep or change? Let's use Small
style: const TextStyle(
fontSize: 11,
color: ThemeConfig.rarityLegendary,
), // 11 is custom, keep or change? Let's use Small
),
),
],

View File

@ -5,6 +5,7 @@ import '../game/data/player_table.dart';
import 'main_wrapper.dart';
import '../widgets/responsive_container.dart';
import '../game/config/theme_config.dart';
import '../game/config/app_strings.dart';
class CharacterSelectionScreen extends StatelessWidget {
const CharacterSelectionScreen({super.key});
@ -83,19 +84,19 @@ class CharacterSelectionScreen extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Text(
"HP: ${warrior.baseHp}",
"${AppStrings.hp}: ${warrior.baseHp}",
style: const TextStyle(
fontWeight: ThemeConfig.fontWeightBold,
),
),
Text(
"ATK: ${warrior.baseAtk}",
"${AppStrings.atk}: ${warrior.baseAtk}",
style: const TextStyle(
fontWeight: ThemeConfig.fontWeightBold,
),
),
Text(
"DEF: ${warrior.baseDefense}",
"${AppStrings.def}: ${warrior.baseDefense}",
style: const TextStyle(
fontWeight: ThemeConfig.fontWeightBold,
),

View File

@ -5,6 +5,7 @@ import '../game/model/item.dart';
import '../game/enums.dart';
import '../utils/item_utils.dart';
import '../game/config/theme_config.dart';
import '../game/config/app_strings.dart';
class InventoryScreen extends StatelessWidget {
const InventoryScreen({super.key});
@ -37,28 +38,28 @@ class InventoryScreen extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatItem(
"HP",
AppStrings.hp,
"${player.hp}/${player.totalMaxHp}",
color: ThemeConfig.statHpColor,
),
_buildStatItem(
"ATK",
AppStrings.atk,
"${player.totalAtk}",
color: ThemeConfig.statAtkColor,
),
_buildStatItem(
"DEF",
AppStrings.def,
"${player.totalDefense}",
color: ThemeConfig.statDefColor,
),
_buildStatItem("Shield", "${player.armor}"),
_buildStatItem(AppStrings.armor, "${player.armor}"),
_buildStatItem(
"Luck",
AppStrings.luck,
"${player.totalLuck}",
color: ThemeConfig.statLuckColor,
),
_buildStatItem(
"Gold",
AppStrings.gold,
"${player.gold} G",
color: ThemeConfig.statGoldColor,
),
@ -162,7 +163,7 @@ class InventoryScreen extends StatelessWidget {
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
item?.name ?? "Empty",
item?.name ?? AppStrings.emptySlot,
textAlign: TextAlign.center,
style: TextStyle(
fontSize:
@ -208,7 +209,7 @@ class InventoryScreen extends StatelessWidget {
child: Align(
alignment: Alignment.centerLeft,
child: Text(
"Bag (${player.inventory.length}/${player.maxInventorySize})",
"${AppStrings.bag} (${player.inventory.length}/${player.maxInventorySize})",
style: const TextStyle(
fontSize: ThemeConfig.fontSizeHeader,
fontWeight: ThemeConfig.fontWeightBold,
@ -371,7 +372,7 @@ class InventoryScreen extends StatelessWidget {
children: [
Icon(Icons.shield, color: ThemeConfig.btnDefendActive),
SizedBox(width: 10),
Text("Equip"),
Text(AppStrings.equip),
],
),
),
@ -391,7 +392,7 @@ class InventoryScreen extends StatelessWidget {
color: ThemeConfig.statGoldColor,
),
const SizedBox(width: 10),
Text("Sell (${item.price} G)"),
Text("${AppStrings.sell} (${item.price} G)"),
],
),
),
@ -407,7 +408,7 @@ class InventoryScreen extends StatelessWidget {
children: [
Icon(Icons.delete, color: ThemeConfig.btnActionActive),
SizedBox(width: 10),
Text("Discard"),
Text(AppStrings.discard),
],
),
),
@ -430,7 +431,7 @@ class InventoryScreen extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text("Cancel"),
child: const Text(AppStrings.cancel),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
@ -440,7 +441,7 @@ class InventoryScreen extends StatelessWidget {
provider.sellItem(item);
Navigator.pop(ctx);
},
child: const Text("Sell", style: TextStyle(color: Colors.black)),
child: const Text(AppStrings.sell, style: TextStyle(color: Colors.black)),
),
],
),
@ -460,7 +461,7 @@ class InventoryScreen extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text("Cancel"),
child: const Text(AppStrings.cancel),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
@ -470,7 +471,7 @@ class InventoryScreen extends StatelessWidget {
provider.discardItem(item);
Navigator.pop(ctx);
},
child: const Text("Discard"),
child: const Text(AppStrings.discard),
),
],
),
@ -510,7 +511,7 @@ class InventoryScreen extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
"Equip ${newItem.name}?",
"${AppStrings.equip} ${newItem.name}?",
style: const TextStyle(fontWeight: ThemeConfig.fontWeightBold),
),
if (oldItem != null)
@ -524,8 +525,8 @@ class InventoryScreen extends StatelessWidget {
const SizedBox(height: 16),
_buildStatChangeRow("Max HP", currentMaxHp, newMaxHp),
_buildStatChangeRow("Current HP", currentHp, newHp),
_buildStatChangeRow("ATK", currentAtk, newAtk),
_buildStatChangeRow("DEF", currentDef, newDef),
_buildStatChangeRow(AppStrings.atk, currentAtk, newAtk),
_buildStatChangeRow(AppStrings.def, currentDef, newDef),
_buildStatChangeRow(
"LUCK",
player.totalLuck,
@ -536,14 +537,14 @@ class InventoryScreen extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text("Cancel"),
child: const Text(AppStrings.cancel),
),
ElevatedButton(
onPressed: () {
provider.equipItem(newItem);
Navigator.pop(ctx);
},
child: const Text("Confirm"),
child: const Text(AppStrings.confirm),
),
],
),
@ -582,27 +583,27 @@ class InventoryScreen extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
"Unequip ${itemToUnequip.name}?",
"${AppStrings.unequip} ${itemToUnequip.name}?",
style: const TextStyle(fontWeight: ThemeConfig.fontWeightBold),
),
const SizedBox(height: 16),
_buildStatChangeRow("Max HP", currentMaxHp, newMaxHp),
_buildStatChangeRow("Current HP", currentHp, newHp),
_buildStatChangeRow("ATK", currentAtk, newAtk),
_buildStatChangeRow("DEF", currentDef, newDef),
_buildStatChangeRow(AppStrings.atk, currentAtk, newAtk),
_buildStatChangeRow(AppStrings.def, currentDef, newDef),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text("Cancel"),
child: const Text(AppStrings.cancel),
),
ElevatedButton(
onPressed: () {
provider.unequipItem(itemToUnequip);
Navigator.pop(ctx);
},
child: const Text("Confirm"),
child: const Text(AppStrings.confirm),
),
],
),
@ -657,10 +658,10 @@ class InventoryScreen extends StatelessWidget {
Widget _buildItemStatText(Item item) {
List<String> stats = [];
if (item.atkBonus > 0) stats.add("+${item.atkBonus} ATK");
if (item.hpBonus > 0) stats.add("+${item.hpBonus} HP");
if (item.armorBonus > 0) stats.add("+${item.armorBonus} DEF");
if (item.luck > 0) stats.add("+${item.luck} Luck");
if (item.atkBonus > 0) stats.add("+${item.atkBonus} ${AppStrings.atk}");
if (item.hpBonus > 0) stats.add("+${item.hpBonus} ${AppStrings.hp}");
if (item.armorBonus > 0) stats.add("+${item.armorBonus} ${AppStrings.def}");
if (item.luck > 0) stats.add("+${item.luck} ${AppStrings.luck}");
// Include effects
List<String> effectTexts = item.effects.map((e) => e.description).toList();

View File

@ -6,6 +6,7 @@ import '../widgets/responsive_container.dart';
import '../game/save_manager.dart';
import '../providers/battle_provider.dart';
import '../game/config/theme_config.dart';
import '../game/config/app_strings.dart';
class MainMenuScreen extends StatefulWidget {
const MainMenuScreen({super.key});
@ -79,7 +80,7 @@ class _MainMenuScreenState extends State<MainMenuScreen> {
),
const SizedBox(height: 20),
const Text(
"COLOSSEUM'S CHOICE",
AppStrings.gameTitle,
style: TextStyle(
fontSize: ThemeConfig.fontSizeHero,
fontWeight: ThemeConfig.fontWeightBold,
@ -112,7 +113,7 @@ class _MainMenuScreenState extends State<MainMenuScreen> {
fontWeight: ThemeConfig.fontWeightBold,
),
),
child: const Text("CONTINUE"),
child: const Text(AppStrings.continueGame),
),
const SizedBox(height: 20),
],
@ -139,7 +140,7 @@ class _MainMenuScreenState extends State<MainMenuScreen> {
fontWeight: ThemeConfig.fontWeightBold,
),
),
child: const Text("NEW GAME"),
child: const Text(AppStrings.startGame),
),
],
),

View File

@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/battle_provider.dart';
import '../providers/settings_provider.dart'; // Import SettingsProvider
import 'main_menu_screen.dart';
import '../game/config/theme_config.dart';
import '../game/config/app_strings.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@ -14,7 +16,7 @@ class SettingsScreen extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Settings',
AppStrings.settings,
style: TextStyle(
fontSize: ThemeConfig.fontSizeTitle,
fontWeight: ThemeConfig.fontWeightBold,
@ -22,11 +24,27 @@ class SettingsScreen extends StatelessWidget {
),
),
const SizedBox(height: 40),
// Placeholder for future settings
const Text(
'Effect Intensity: Normal',
// Enemy Animation Toggle
Consumer<SettingsProvider>(
builder: (context, settings, child) {
return SizedBox(
width: 300,
child: SwitchListTile(
title: const Text(
AppStrings.enemyAnimations,
style: TextStyle(color: ThemeConfig.textColorWhite),
),
value: settings.enableEnemyAnimations,
onChanged: (value) {
settings.toggleEnemyAnimations(value);
},
activeColor: ThemeConfig.btnActionActive,
),
);
},
),
const SizedBox(height: 20),
const Text(
'Volume: 100%',
@ -43,7 +61,7 @@ class SettingsScreen extends StatelessWidget {
onPressed: () {
_showConfirmationDialog(
context,
title: 'Restart Game?',
title: '${AppStrings.restart} Game?',
content: 'All progress will be lost. Are you sure?',
onConfirm: () {
context.read<BattleProvider>().initializeBattle();
@ -70,24 +88,32 @@ class SettingsScreen extends StatelessWidget {
onPressed: () {
_showConfirmationDialog(
context,
title: 'Return to Main Menu?',
content: 'Unsaved progress may be lost. (Progress is saved automatically after each stage)',
title: '${AppStrings.returnToMenu}?',
content:
'Unsaved progress may be lost. (Progress is saved automatically after each stage)',
onConfirm: () {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const MainMenuScreen()),
MaterialPageRoute(
builder: (context) => const MainMenuScreen(),
),
(route) => false,
);
},
);
},
child: const Text('Return to Main Menu'),
child: const Text(AppStrings.returnToMenu),
),
],
),
);
}
void _showConfirmationDialog(BuildContext context, {required String title, required String content, required VoidCallback onConfirm}) {
void _showConfirmationDialog(
BuildContext context, {
required String title,
required String content,
required VoidCallback onConfirm,
}) {
showDialog(
context: context,
builder: (context) => AlertDialog(
@ -96,14 +122,17 @@ class SettingsScreen extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: const Text(AppStrings.cancel),
),
TextButton(
onPressed: () {
Navigator.pop(context);
onConfirm();
},
child: const Text('Confirm', style: TextStyle(color: Colors.red)),
child: const Text(
AppStrings.confirm,
style: TextStyle(color: Colors.red),
),
),
],
),

View File

@ -105,6 +105,41 @@ class BattleAnimationWidgetState extends State<BattleAnimationWidget>
}
}
Future<void> animateDefense(VoidCallback onImpact) async {
// Defense: Wobble/Shake horizontally
_translateController.duration = const Duration(milliseconds: 800);
// Sequence: Left -> Right -> Center
_translateAnimation =
TweenSequence<Offset>([
TweenSequenceItem(
tween: Tween<Offset>(begin: Offset.zero, end: const Offset(-10, 0)),
weight: 25,
),
TweenSequenceItem(
tween: Tween<Offset>(
begin: const Offset(-10, 0),
end: const Offset(10, 0),
),
weight: 50,
),
TweenSequenceItem(
tween: Tween<Offset>(begin: const Offset(10, 0), end: Offset.zero),
weight: 25,
),
]).animate(
CurvedAnimation(
parent: _translateController,
curve: Curves.easeInOut,
),
);
await _translateController.forward();
if (!mounted) return;
onImpact();
_translateController.reset();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(

View File

@ -125,7 +125,7 @@ class CharacterStatusCard extends StatelessWidget {
),
const SizedBox(height: 8), //
if (!isPlayer)
if (!isPlayer && !hideStats)
Consumer<BattleProvider>(
builder: (context, provider, child) {
if (provider.currentEnemyIntent != null && !character.isDead) {

View File

@ -286,6 +286,11 @@ class FloatingFeedbackTextState extends State<FloatingFeedbackText>
class FeedbackTextData {
final String id;
final Widget widget;
final String eventId; // To prevent duplicates
FeedbackTextData({required this.id, required this.widget});
FeedbackTextData({
required this.id,
required this.widget,
required this.eventId,
});
}

View File

@ -7,161 +7,84 @@
- **프로젝트명:** Colosseum's Choice
- **플랫폼:** Flutter (Android/iOS/Web/Desktop)
- **장르:** 텍스트 기반의 턴제 RPG + GUI (로그라이크 요소 포함)
- **상태:** 프로토타입 단계 (전투 시각화, 데이터 주도 시스템, 반응형 UI 구현 완료)
- **상태:** 핵심 시스템 구현 완료 및 안정화 (i18n 구조 적용, 애니메이션 동기화 완료)
## 2. 현재 구현된 핵심 기능 (Feature Status)
### A. 게임 흐름 (Game Flow)
1. **메인 메뉴 (`MainMenuScreen`):** 게임 시작 버튼.
2. **캐릭터 선택 (`CharacterSelectionScreen`):** 'Warrior' 직업 구현.
3. **메인 게임 (`MainWrapper`):** 하단 탭 네비게이션 (Battle / Inventory).
4. **반응형 레이아웃 (Responsive UI):**
- `ResponsiveContainer` 위젯을 통해 최대 너비(600px) 및 높이(1000px) 제한.
- 웹/태블릿 환경에서도 모바일 앱처럼 중앙 정렬된 화면 제공.
- **Battle UI Layout:** 플레이어(좌측 하단) vs 적(우측 상단) 대각선 구도 배치 및 캐릭터 임시 아이콘 적용.
- **Widget Refactoring:** `BattleScreen`의 주요 UI 컴포넌트(`CharacterStatusCard`, `BattleLogOverlay` 등)를 `lib/widgets/battle/`로 분리하여 모듈화.
1. **메인 메뉴 (`MainMenuScreen`):** 게임 시작, 이어하기(저장된 데이터 있을 시), 설정 버튼.
2. **캐릭터 선택 (`CharacterSelectionScreen`):** 'Warrior' 직업 구현 (스탯 확인 후 시작).
3. **메인 게임 (`MainWrapper`):** 하단 탭 네비게이션 (Battle / Inventory / Settings).
4. **설정 (`SettingsScreen`):**
- 적 애니메이션 활성화/비활성화 토글 (`SettingsProvider` 연동).
- 게임 재시작, 메인 메뉴로 돌아가기 기능.
5. **반응형 레이아웃 (Responsive UI):**
- `ResponsiveContainer`를 통해 다양한 화면 크기 대응 (최대 너비/높이 제한).
- Battle UI: 플레이어(좌하단) vs 적(우상단) 대각선 구도.
### B. 전투 시스템 (`BattleProvider`)
- **턴제 전투:** 플레이어 턴 -> 적 턴.
- **행동 선택:** 공격(Attack) / 방어(Defend).
- **리스크 시스템 (Risk System):** Safe(100%/50%), Normal(80%/100%), Risky(40%/200%) 선택.
- **리스크 시스템 (Risk System):**
- **Safe:** 성공률 100%+, 효율 50%.
- **Normal:** 성공률 80%+, 효율 100%.
- **Risky:** 성공률 40%+, 효율 200% (성공 시 강력한 이펙트).
- **Luck 보정:** `totalLuck` 1당 성공률 +1%.
- **적 인공지능 (Enemy AI & Intent):**
- **Intent UI:** 적의 다음 행동(공격/방어, 리스크)을 미리 표시.
- **선제 방어 (Pre-emptive Defense):** 적이 방어를 선택하면 턴 시작 전에 즉시 방어도가 적용됨.
- **Defense Restriction:** `DefenseForbidden` 상태 시 방어 행동 선택 불가.
- **Variance Removed:** 적의 공격/방어 수치 계산 시 랜덤 분산(Variance) 제거 (고정 수치).
- **적 장비 시스템 (Enemy Equipment):**
- 적에게 아이템 장착 가능 (`enemies.json`의 `equipment` 필드).
- 장착된 아이템의 스탯 및 특수 효과(상태이상 등)가 전투 시 적용됨.
- **시각 효과 (Visual Effects):**
- **Floating Text:** 데미지 발생 시 캐릭터 위에 데미지 수치가 떠오름 (일반: 빨강, 출혈: 보라, 취약: 주황).
- **Action Feedback:** 공격 빗나감(MISS - 적 위치/회색) 및 방어 실패(FAILED - 내 위치/빨강) 텍스트 오버레이.
- **Effect Icons:** 공격/방어 및 리스크 레벨에 따른 아이콘 애니메이션 출력.
- **Advanced Animations:**
- **Risk-Based:** Safe(Wobble), Normal(Dash), Risky(Scale Up + Heavy Dash + Shake + Explosion).
- **Icon-Only:** 공격 시 캐릭터 아이콘만 이동하며, 스탯 정보(HP/Armor)는 일시적으로 숨김 처리.
- **Impact Sync:** 타격 이펙트와 데미지 텍스트가 애니메이션 타격 시점에 정확히 동기화됨.
- **Intent UI:** 적의 다음 행동(공격/방어, 데미지/방어도) 미리 표시.
- **동기화된 애니메이션:** 적 행동 결정(`_generateEnemyIntent`)은 이전 애니메이션이 완전히 끝난 후 이루어짐.
- **선제 방어:** 적이 방어 행동을 선택하면 턴 시작 시 즉시 방어도가 적용됨.
- **애니메이션 및 타격감 (Visuals & Impact):**
- **UI 주도 Impact 처리:** 애니메이션 타격 시점(`onImpact`)에 정확히 데미지가 적용되고 텍스트가 뜸 (완벽한 동기화).
- **적 돌진:** 적도 공격 시 플레이어 위치로 돌진함 (설정에서 끄기 가능).
- **이펙트:** 타격 아이콘, 데미지 텍스트(Floating Text), 화면 흔들림(`ShakeWidget`), 폭발(`ExplosionWidget`).
- **상태이상:** `Stun`, `Bleed`, `Vulnerable`, `DefenseForbidden`.
- **행운 시스템 (Luck System):**
- 아이템 옵션으로 `luck` 스탯 제공.
- `totalLuck` 수치만큼 행동(공격/방어) 성공 확률 증가 (1 Luck = +1%).
- 성공 확률은 최대 100%로 제한됨.
- **UI:** 인벤토리에서 Luck 수치 확인 가능, 전투 시 Risk 선택 창에서 보정된 확률 표시.
### C. 데이터 주도 설계 (Data-Driven Design)
### C. 데이터 및 로직 (Architecture)
- **JSON 데이터:** `assets/data/items.json` (ID 포함), `assets/data/enemies.json` (장비 포함).
- **데이터 로더:** `ItemTable` (ID 조회 지원), `EnemyTable` (장비 장착 지원).
- **Data-Driven:** `items.json`, `enemies.json`, `players.json`.
- **Logic 분리:**
- `BattleProvider`: UI 상태 관리 및 이벤트 스트림(`damageStream`, `effectStream`) 발송.
- `CombatCalculator`: 데미지 공식, 확률 계산, 상태이상 로직 순수 함수화.
- `BattleLogManager`: 전투 로그 관리.
- `LootGenerator`: 아이템 생성, 접두사(Prefix) 부여, 랜덤 스탯 로직.
- `SettingsProvider`: 전역 설정(애니메이션 on/off 등) 관리 및 영구 저장.
- **Soft i18n:** UI 텍스트는 `lib/game/config/app_strings.dart`에서 통합 관리.
- **Config:** `GameConfig`, `BattleConfig`, `ItemConfig` 등 설정 값 중앙화.
### D. 아이템 및 경제 (`Item`, `Inventory`)
### D. 아이템 및 경제
- **장비:** 무기, 방어구, 방패, 장신구.
- **아이콘 및 색상 (`ItemUtils`):**
- 무기: 빨강 삼각형 (`Icons.change_history`)
- 방패: 파랑 방패 (`Icons.shield`)
- 갑옷: 파랑 옷 (`Icons.checkroom`)
- 장신구: 보라 다이아몬드 (`Icons.diamond`)
- **가격:** JSON 고정 가격 사용. 판매 시 60% (`GameMath.floor`) 획득.
- **인벤토리:** 장착 슬롯 및 가방(Bag) 그리드 UI 구현.
- **아이템 시스템 (Item System):**
- **Rarity (희귀도):** Common, Rare, Epic, Legendary. (드랍 확률 관여)
- **Tier (티어):** 1티어(초반), 2티어(중반), 3티어(후반). (라운드 진행도에 따라 등장 제한)
- **획득 로직:** 현재 라운드(Tier)에 맞는 아이템 풀 내에서 Rarity 확률에 따라 결정.
- **시스템:**
- **Rarity:** Common ~ Unique.
- **Tier:** 라운드 진행도에 따라 상위 티어 아이템 등장.
- **Prefix:** Rarity에 따라 접두사가 붙으며 스탯이 변형됨 (예: "Sharp Wooden Sword").
- **상점 (`ShopProvider`):** 아이템 구매/판매, 리롤(Reroll), 인벤토리 관리.
### E. 스테이지 시스템 (`StageModel`)
### E. 저장 및 진행 (Persistence)
- **타입:** Battle, Shop, Rest, Elite.
- **적 등장 테이블 (Enemy Pool):** 적 조우 시, 현재 스테이지(라운드)에 따라 등장 가능한 적 목록(`enemy_table_pool`)을 설정하여 해당 범위 내에서 적을 랜덤 생성해야 함.
- **게임 구조 (Game Structure):**
- **총 3라운드 (3 Rounds):** 각 라운드는 12개의 스테이지로 구성 (12/12/12).
- **라운드 구성:**
1. **1라운드:** 지하 불법 투기장 (Underground Illegal Arena)
2. **2라운드:** 콜로세움 (Colosseum)
3. **3라운드:** 왕의 투기장 (King's Arena) - 최종 보스(Final Boss) 등장.
- **자동 저장:** 스테이지 클리어 시 `SaveManager`를 통해 자동 저장.
- **Permadeath:** 패배 시 저장 데이터 삭제 (로그라이크 요소).
### F. 시스템 및 설정 (System & Settings)
## 3. 작업 컨벤션 (Working Conventions)
- **설정 페이지 (Settings Screen):**
- 게임 재시작 (Restart Game) 및 메인 메뉴로 돌아가기 (Return to Main Menu) 기능.
- 하단 네비게이션 바(BottomNavigationBar)에 설정 탭 추가.
- **로컬 저장 (Local Storage):**
- `shared_preferences`를 사용하여 스테이지 클리어 시 자동 저장.
- 메인 메뉴에서 '이어하기 (CONTINUE)' 버튼을 통해 저장된 시점부터 게임 재개 가능.
- 저장 데이터: 스테이지 진행도, 턴 수, 플레이어 상태(체력, 장비, 인벤토리 등).
## 3. 핵심 파일 및 아키텍처
- **`lib/providers/battle_provider.dart`:**
- **Core Logic:** 상태 관리, 전투 루프.
- **Streams:** `damageStream`, `effectStream`을 통해 UI(`BattleScreen`)에 비동기 이벤트 전달.
- **`lib/game/enums.dart`:** 프로젝트 전반의 Enum 통합 관리 (`ActionType`, `RiskLevel`, `StageType` 등).
- **`lib/utils/item_utils.dart`:** 아이템 타입별 아이콘 및 색상 로직 중앙화.
- **`lib/widgets/battle/`:** `BattleScreen`에서 분리된 재사용 가능한 위젯들.
- **UI Components:** `CharacterStatusCard`, `BattleLogOverlay`, `FloatingBattleTexts`, `StageUI`.
- **Effects:** `BattleAnimationWidget` (공격 애니메이션), `ExplosionWidget` (파티클), `ShakeWidget` (화면 흔들림).
- **`lib/widgets/responsive_container.dart`:** 반응형 레이아웃 컨테이너.
- **`lib/game/model/`:**
- `damage_event.dart`, `effect_event.dart`: 이벤트 모델.
- `entity.dart`: `Character` (Player/Enemy).
- `item.dart`: `Item` (ID 필드 포함).
- **`lib/screens/battle_screen.dart`:**
- `StreamSubscription`을 통해 이펙트 이벤트 수신 및 `Overlay` 애니메이션 렌더링.
- `Stack``Positioned` 기반의 정교한 레이아웃.
## 4. 작업 컨벤션 (Working Conventions)
- **Prompt Driven Development:** `prompt/XX_description.md` 유지.
- **유사 작업 통합:** 작업 내용이 이전 프롬프트와 유사한 경우 새로운 프롬프트를 생성하지 않고 기존 프롬프트에 내용을 추가합니다.
- **Language:** **모든 프롬프트 파일(prompt/XX\_...)은 반드시 한국어(Korean)로 작성해야 합니다.**
- **Prompt Driven Development:** `prompt/XX_description.md` 유지. (유사 작업 통합 및 인덱스 정리 권장)
- **i18n Strategy (Soft i18n):** UI에 표시되는 문자열은 하드코딩하지 않고 `lib/game/config/app_strings.dart`의 상수를 사용해야 합니다. (전투 로그 등 동적 문자열 제외)
- **Config Management:** 하드코딩되는 값들은 `config` 폴더 내 파일들(`lib/game/config/` 등)에서 통합 관리할 수 있도록 작성해야 합니다.
- **State Management:** `Provider` + `Stream` (이벤트성 데이터).
- **Data:** JSON 기반.
- **State Management:** `Provider` (UI 상태) + `Stream` (이벤트성 데이터).
- **Data:** JSON 기반 + `Table` 클래스로 로드.
## 5. 다음 단계 작업 (Next Steps)
## 4. 최근 주요 변경 사항 (Change Log)
1. **아이템 시스템 고도화:** `items.json``rarity`, `tier` 필드 추가 및 `ItemTable` 로직 수정.
2. **[x] 상점 구매 기능:** `Shop` 스테이지 구매 UI 구현 (Tier/Rarity 기반 목록 생성).
3. **적 등장 테이블 구현:** 스테이지별 등장 가능한 적 목록(`enemy_table_pool`) 설정 및 적용.
4. **이미지 리소스 적용:** JSON 경로에 맞는 실제 이미지 파일 추가 및 UI 표시.
5. **밸런싱 및 콘텐츠 확장:** 아이템/적 데이터 추가 및 밸런스 조정.
- **[Refactor] BattleProvider:** `CombatCalculator`, `BattleLogManager`, `LootGenerator` 분리로 코드 다이어트.
- **[Refactor] Animation Sync:** `Future.delayed` 예측 방식을 버리고, UI 애니메이션 콜백(`onImpact`)을 통해 로직을 트리거하는 방식으로 변경하여 타격감 동기화 해결.
- **[Refactor] Settings:** `SettingsProvider` 도입 및 적 애니메이션 토글 기능 추가.
- **[Fix] Bugs:** 아이템 이름 생성 오류 수정, 리워드 팝업 깜빡임 및 중복 생성 수정, 앱 크래시(Null Safety) 수정.
## 6. 장기 목표 (Future Roadmap / TODO)
## 5. 다음 단계 (Next Steps)
- [ ] **출혈 상태 이상 조건 변경:** 공격 시 상대방의 방어도에 의해 공격이 완전히 막힐 경우, 출혈 상태 이상이 적용되지 않도록 로직 변경.
- [ ] **장비 분해 시스템 (적 장비):** 플레이어 장비의 옵션으로 상대방의 장비를 분해하여 '언암드' 상태로 만들 수 있는 시스템 구현.
- [ ] **플레이어 공격 데미지 분산(Variance) 적용 여부 검토:** 현재 적에게만 적용된 +/- 20% 데미지 분산을 플레이어에게도 적용할지 결정.
- [x] **애니메이션 및 타격감 고도화:**
- 캐릭터별 이미지 추가 및 하스스톤 스타일의 공격 모션(대상에게 돌진 후 타격) 구현 완료 (Icon-Only Animation).
- **Risky 공격:** 하스스톤의 7데미지 이상 타격감(화면 흔들림, 강렬한 파티클 및 임팩트) 구현 완료.
- [ ] **체력 조건부 특수 능력:** 캐릭터의 체력이 30% 미만일 때 발동 가능한 특수 능력 시스템 구현.
- [ ] **영구 스탯 수정자 로직 적용 (필수):**
- 현재 `Character` 클래스에 `permanentModifiers` 필드만 선언되어 있음.
- 추후 `totalAtk`, `totalDefense`, `totalMaxHp` 계산 시 이 수정자들을 반드시 반영해야 함.
- [ ] **Google OAuth 로그인 및 계정 연동:**
- Firebase Auth 등을 활용한 구글 로그인 구현.
- Firestore 또는 Realtime Database에 유저 계정 정보(진행 상황, 재화 등) 저장 및 불러오기 기능 추가.
- _Note: 이 기능은 게임의 핵심 로직이 안정화된 후, 완전 나중에 진행할 예정입니다._
- [ ] **설정 페이지 (Settings Page) 구현 (Priority: Very Low):**
- **이펙트 강도 조절 (Effect Intensity):** 1 ~ 999 범위로 설정 가능.
- **Easter Egg:** 강도를 999로 설정하고 Risky 공격 성공 시, "심각한 오류로 프로세스가 종료되었습니다" 같은 페이크 시스템 팝업 출력.
---
**이 프롬프트를 읽은 AI 에이전트는 위 내용을 바탕으로 즉시 개발을 이어가십시오.**
## 7. 프롬프트 히스토리 (Prompt History)
- [x] 45_config_refactoring.md
- [x] 46_shop_refactoring.md
- [x] 47_inventory_full_handling.md
- [x] 48_refactor_stage_ui.md
- [x] 49_implement_item_icons.md
- [x] 50_expand_item_pool.md
- [x] 51_refactor_prefix_table.md
- [x] 52_round_based_enemy_pool.md
- [x] 53_refine_stage_rewards.md
- [x] 54_fix_shop_logic.md
- [x] 55_fix_shop_ui_sync.md
- [x] 56_permadeath_implementation.md
1. **밸런싱:** 현재 몬스터 및 아이템 스탯 미세 조정.
2. **콘텐츠 확장:** 더 많은 아이템, 적, 스킬 패턴 추가.
3. **튜토리얼:** 신규 유저를 위한 가이드 추가.

View File

@ -0,0 +1,20 @@
# 57. BattleProvider Refactoring
## 1. 목표 (Goal)
- 비대해진 `BattleProvider` 클래스(약 900라인)를 역할별로 분리하여 유지보수성을 높이고 가독성을 개선합니다.
- `CombatCalculator`(전투 계산)와 `BattleLogManager`(로그 관리) 클래스를 도입합니다.
## 2. 구현 계획 (Implementation Plan)
1. **디렉토리 생성:** `lib/game/logic` 폴더를 생성하여 로직 클래스들을 모아둡니다.
2. **`BattleLogManager` 분리:**
- 전투 로그 리스트(`_battleLogs`)와 로그 추가 메서드(`logBattleInfo`)를 전담하는 클래스를 생성합니다.
3. **`CombatCalculator` 분리:**
- 공격/방어 성공 확률, 데미지 산출 로직, 상태이상 적용 확률 등 순수 계산 로직을 분리합니다.
4. **`BattleProvider` 수정:**
- 위 클래스들을 인스턴스로 포함하고, 해당 로직을 위임(delegation) 처리합니다.
- `ChangeNotifier`로서의 UI 상태 관리 책임은 유지합니다.
## 3. 기대 효과 (Expected Outcome)
- `BattleProvider`의 코드 라인 수 감소.
- 전투 공식 수정 시 `CombatCalculator`만 수정하면 되므로 안전성 확보.
- 로그 포맷이나 저장 방식 변경 시 `BattleLogManager`만 수정하면 됨.

View File

@ -0,0 +1,18 @@
# 58. Refactor Item Creation Logic
## 1. 목표 (Goal)
- `ItemTemplate` 클래스 내부에 강하게 결합된 아이템 생성 및 접두사(Prefix) 적용 로직을 분리합니다.
- `LootGenerator` 클래스를 생성하여 전리품 생성 및 옵션 부여 로직을 중앙화합니다.
## 2. 구현 계획 (Implementation Plan)
1. **`LootGenerator` 생성 (`lib/game/logic/loot_generator.dart`):**
- `ItemTemplate`을 입력받아 실제 `Item` 객체를 생성하는 static 메서드 `generate`를 구현합니다.
- 기존 `createItem`에 있던 Rarity별 접두사 처리, 스탯 보정, 이름 변경 로직을 이곳으로 이동합니다.
2. **`ItemTemplate` 수정 (`lib/game/data/item_table.dart`):**
- `createItem` 메서드가 `LootGenerator`를 호출하도록 변경하거나, 해당 메서드를 제거하고 호출부(`BattleProvider`, `EnemyTemplate`)에서 `LootGenerator`를 직접 사용하도록 수정합니다.
- (호환성을 위해 `createItem``LootGenerator`를 호출하는 래퍼로 남겨두는 것을 권장)
## 3. 기대 효과 (Expected Outcome)
- `ItemTemplate`은 순수한 데이터 정의(DTO) 역할에 집중.
- 아이템 생성 알고리즘(접두사, 랜덤 스탯 등)이 변경되더라도 데이터 클래스에는 영향 없음.
- 추후 '제작(Crafting)' 시스템이나 '상점 전용 생성' 등 다양한 생성 규칙 추가 시 `LootGenerator` 확장 용이.

View File

@ -0,0 +1,19 @@
# 59. Centralize Constants and Configuration
## 1. 목표 (Goal)
- 코드 곳곳에 흩어져 있는 '매직 넘버(Magic Numbers)'와 하드코딩된 설정 값들을 `lib/game/config/` 폴더 내의 설정 파일들로 중앙화합니다.
- 특히 전투 공식, 확률, 아이템 생성 가중치 등을 설정 파일로 이동하여 밸런스 조정 및 유지보수를 용이하게 합니다.
## 2. 구현 계획 (Implementation Plan)
1. **설정 파일 업데이트:**
- `BattleConfig`: 리스크 레벨별 확률, 효율(Efficiency), 데미지 분산 범위(현재는 제거됨, 필요 시 부활), 상태이상 확률 등.
- `ItemConfig`: 아이템 생성 시 Rarity 가중치(이미 일부 존재), Prefix 등장 확률 등.
- `GameConfig`: 골드 보상 공식 상수, 스테이지 관련 상수 등.
2. **코드 리팩토링:**
- `CombatCalculator`: 하드코딩된 리스크 확률(0.5, 0.8, 0.4 등)과 효율(0.5, 1.0, 2.0)을 `BattleConfig` 상수로 대체.
- `LootGenerator`: Prefix 등장 확률(50% 등)을 `ItemConfig` 상수로 대체.
- `BattleProvider`: 골드 계산 공식 상수를 `GameConfig`로 이동.
## 3. 기대 효과 (Expected Outcome)
- 게임 밸런스 조정 시 코드 로직을 건드리지 않고 `config` 파일만 수정하면 됨.
- 코드의 가독성이 향상됨 (숫자의 의미가 변수명으로 명확해짐).

View File

@ -0,0 +1,27 @@
# 61. System Stabilization & Refactoring (Summary)
## 1. 개요
이 문서는 프로젝트 안정화 및 리팩토링 과정에서 진행된 61번부터 76번까지의 작업 내용을 요약 및 통합한 것입니다.
## 2. 주요 변경 사항
### A. 구조 개선 및 리팩토링
- **i18n 적용 (Soft i18n):** `AppStrings.dart`를 도입하여 UI 텍스트를 중앙화했습니다.
- **설정 시스템 (`SettingsProvider`):** 적 애니메이션 On/Off 등 게임 설정을 관리하고 영구 저장하는 시스템을 구축했습니다.
- **전투 로직 동기화 (UI-Driven Impact):**
- 기존 `Future.delayed` 기반의 불안정한 타이밍 로직을 제거했습니다.
- UI(`BattleScreen`)의 애니메이션 타격 시점(`onImpact`)에 `BattleProvider`의 데미지 로직(`handleImpact`)을 호출하는 구조로 변경하여 시각 효과와 데이터 처리를 완벽하게 동기화했습니다.
- **적 Intent 생성 지연:** 적의 공격 애니메이션이 완전히 끝난 후 다음 행동을 결정하도록 하여, 시각적 혼란(공격 중 방어 이펙트 출력 등)을 방지했습니다.
### B. 버그 수정
- **Null Safety Crash:** 공격 실패 시 `EffectEvent`의 null 값을 참조하여 앱이 종료되는 문제를 수정했습니다.
- **리워드 시스템:**
- 리워드 팝업이 깜빡이거나 이전 데이터를 보여주는 문제 수정.
- 승리 시 리워드가 중복 생성(두 번 호출)되는 문제 수정.
- **아이템 이름:** `LootGenerator`의 문자열 보간 오류로 인해 "Instance of..."가 출력되던 문제를 수정했습니다.
- **애니메이션 중복:** 적 캐릭터 카드에 애니메이션 위젯이 중복 적용되어 발생하던 이상 현상을 수정했습니다.
### C. 기능 추가
- **적 공격 애니메이션:** 플레이어와 마찬가지로 적도 공격 시 대상을 향해 돌진하는 애니메이션을 추가했습니다.
이 작업들을 통해 게임의 안정성, 코드의 유지보수성, 그리고 플레이어의 시각적 경험이 크게 향상되었습니다.

View File

@ -0,0 +1,36 @@
# 62. 애니메이션 및 피드백 동기화 관련 이슈 진행 현황
## 1. 문제 발생 현상
* 플레이어 공격 실패(`MISS`) 시, 화면에 `MISS` 텍스트가 두 번 올라오는 현상 발생.
* 적의 방어 실패(`FAILED`) 시에도 유사한 중복 텍스트 현상 발생.
* 로그상 (`[UI Debug] Feedback Event`)으로는 이벤트가 한 번만 발생했지만, UI에는 두 번 표시됨.
* `_addFloatingEffect` 함수 내부에 `eventId` 기반의 중복 체크 로직이 추가되었음에도 현상 지속.
## 2. 진단 및 해결 시도
### 2.1. 원인 가설
1. **`BattleScreen` 인스턴스 중복:** 가장 유력한 가설. 하나의 `EffectEvent`가 발생했을 때, 여러 `BattleScreen` 인스턴스가 각자 이벤트를 받아 화면에 피드백 텍스트를 띄우는 경우.
* `[UI Debug] BattleScreen initialized: ${hashCode}` 로그로 확인 필요. (현재 확인되지 않음)
2. **`_addFloatingEffect` 내부의 `setState` 문제:** `setState` 호출 시 `_floatingFeedbackTexts` 리스트에 위젯이 중복으로 추가되거나, 위젯 렌더링 과정에서 불필요한 복제가 발생하는 경우. (리스트 `clear()` 로직 추가로 해결 시도 중)
3. **UI 렌더링 타이밍/시각적 착시:** `FloatingFeedbackText` 위젯의 생명주기가 꼬여서 이전 텍스트가 완전히 사라지기 전에 새 텍스트가 뜨거나, 애니메이션이 반복되는 것처럼 보이는 착시.
### 2.2. 현재까지 적용된 주요 조치
* **`EffectEvent` `eventId` 기반 중복 체크 (UI 레벨):** `_addFloatingEffect` 함수에서 `eventId`를 기반으로 동일한 이벤트에 대한 피드백 텍스트가 이미 리스트에 있다면 추가하지 않도록 `_floatingFeedbackTexts.any((e) => e.eventId == event.id)` 로직 추가.
* **`_floatingFeedbackTexts.clear()` 도입:** 새로운 피드백 텍스트(`MISS`/`FAILED`)가 뜰 때, 기존의 모든 피드백 텍스트를 리스트에서 제거한 후 추가하도록 수정. (화면에 항상 하나의 피드백 텍스트만 유지)
* **`addPostFrameCallback` 제거:** `_addFloatingEffect``WidgetsBinding.instance.addPostFrameCallback` 제거 (불필요한 비동기 지연 및 잠재적 문제 방지).
* **디버그 로그 추가:**
* `[UI Debug] BattleScreen initialized: ${hashCode}` (`BattleScreen` 초기화 횟수 확인용)
* `[UI Debug] Feedback Event: ${event.id}, Type: ${event.feedbackType}` (`_addFloatingEffect` 호출 확인용)
* `FloatingFeedbackText``event.id` 전체 표시 (화면상 ID 일치 여부 확인용)
## 3. 남아있는 문제 (현재 진단)
* 로그상 `[UI Debug] Feedback Event`는 한 번만 찍히지만, **화면에는 `MISS` 텍스트가 두 번 표시됨.**
* 이는 **UI 레벨에서의 렌더링 문제**이거나, `_addFloatingEffect` 함수 **내부 로직 중 `setState`가 비정상적으로 두 번 호출**되는 문제일 가능성이 높습니다.
* `_floatingFeedbackTexts.clear()` 로직이 추가되었으므로, 같은 리스트에 두 번 추가되는 것은 막혔을 것입니다.
## 4. 다음 단계 제안
* **`[UI Debug] BattleScreen initialized: ...` 로그 결과 확인:** 이 로그가 두 번 이상 찍힌다면 `BattleScreen` 인스턴스가 중복된 것이므로, `MainWrapper`나 라우팅 구조를 점검해야 합니다.
* **화면상 `MISS` 텍스트의 ID 확인:** 화면에 보이는 두 개의 `MISS` 텍스트의 ID가 **정확히 동일한지** 확인 필요 (현재 `event.id` 전체를 표시하도록 수정됨).
* **ID가 동일하다면:** 하나의 `FeedbackTextData` 객체가 UI에 중복 렌더링되는 문제. (Key 문제, `Stack` 리빌드 문제 등)
* **ID가 다르다면:** `_addFloatingEffect` 자체가 두 번 호출된 것. (로그가 하나라는 것과 모순됨. 로그 시스템 확인 필요)
**현재까지의 모든 문제 해결 노력은 `BattleProvider` 내의 로직 중복이나 타이밍 오류를 잡는 데 초점을 맞췄습니다. 하지만 `MISS` 텍스트 중복 문제는 `BattleScreen` (UI) 쪽에서 발생하는 현상으로 보입니다.**