This commit is contained in:
Horoli 2025-12-04 16:50:57 +09:00
parent 0a7c50e6c9
commit 37a634643e
23 changed files with 1063 additions and 664 deletions

View File

@ -5,35 +5,40 @@
"baseHp": 20, "baseHp": 20,
"baseAtk": 5, "baseAtk": 5,
"baseDefense": 5, "baseDefense": 5,
"image": "assets/images/enemies/goblin.png" "image": "assets/images/enemies/goblin.png",
"equipment": ["rusty_dagger"]
}, },
{ {
"name": "Slime", "name": "Slime",
"baseHp": 30, "baseHp": 30,
"baseAtk": 3, "baseAtk": 3,
"baseDefense": 5, "baseDefense": 5,
"image": "assets/images/enemies/slime.png" "image": "assets/images/enemies/slime.png",
"equipment": ["rusty_dagger"]
}, },
{ {
"name": "Wolf", "name": "Wolf",
"baseHp": 25, "baseHp": 25,
"baseAtk": 7, "baseAtk": 7,
"baseDefense": 5, "baseDefense": 5,
"image": "assets/images/enemies/wolf.png" "image": "assets/images/enemies/wolf.png",
"equipment": ["rusty_dagger"]
}, },
{ {
"name": "Bandit", "name": "Bandit",
"baseHp": 35, "baseHp": 35,
"baseAtk": 6, "baseAtk": 6,
"baseDefense": 5, "baseDefense": 5,
"image": "assets/images/enemies/bandit.png" "image": "assets/images/enemies/bandit.png",
"equipment": ["rusty_dagger"]
}, },
{ {
"name": "Skeleton", "name": "Skeleton",
"baseHp": 15, "baseHp": 15,
"baseAtk": 8, "baseAtk": 8,
"baseDefense": 5, "baseDefense": 5,
"image": "assets/images/enemies/skeleton.png" "image": "assets/images/enemies/skeleton.png",
"equipment": ["rusty_dagger"]
} }
], ],
"elite": [ "elite": [
@ -42,21 +47,24 @@
"baseHp": 60, "baseHp": 60,
"baseAtk": 12, "baseAtk": 12,
"baseDefense": 3, "baseDefense": 3,
"image": "assets/images/enemies/orc_warrior.png" "image": "assets/images/enemies/orc_warrior.png",
"equipment": ["battle_axe", "leather_vest"]
}, },
{ {
"name": "Giant Spider", "name": "Giant Spider",
"baseHp": 50, "baseHp": 50,
"baseAtk": 15, "baseAtk": 15,
"baseDefense": 2, "baseDefense": 2,
"image": "assets/images/enemies/giant_spider.png" "image": "assets/images/enemies/giant_spider.png",
"equipment": ["jagged_dagger"]
}, },
{ {
"name": "Dark Knight", "name": "Dark Knight",
"baseHp": 80, "baseHp": 80,
"baseAtk": 10, "baseAtk": 10,
"baseDefense": 5, "baseDefense": 5,
"image": "assets/images/enemies/dark_knight.png" "image": "assets/images/enemies/dark_knight.png",
"equipment": ["stunning_hammer", "kite_shield"]
} }
] ]
} }

View File

@ -1,6 +1,7 @@
{ {
"weapons": [ "weapons": [
{ {
"id": "rusty_dagger",
"name": "Rusty Dagger", "name": "Rusty Dagger",
"description": "Old and rusty, but better than nothing.", "description": "Old and rusty, but better than nothing.",
"baseAtk": 3, "baseAtk": 3,
@ -9,6 +10,7 @@
"image": "assets/images/items/rusty_dagger.png" "image": "assets/images/items/rusty_dagger.png"
}, },
{ {
"id": "iron_sword",
"name": "Iron Sword", "name": "Iron Sword",
"description": "A standard soldier's sword.", "description": "A standard soldier's sword.",
"baseAtk": 8, "baseAtk": 8,
@ -17,6 +19,7 @@
"image": "assets/images/items/iron_sword.png" "image": "assets/images/items/iron_sword.png"
}, },
{ {
"id": "battle_axe",
"name": "Battle Axe", "name": "Battle Axe",
"description": "Heavy but powerful.", "description": "Heavy but powerful.",
"baseAtk": 12, "baseAtk": 12,
@ -25,6 +28,7 @@
"image": "assets/images/items/battle_axe.png" "image": "assets/images/items/battle_axe.png"
}, },
{ {
"id": "stunning_hammer",
"name": "Stunning Hammer", "name": "Stunning Hammer",
"description": "A heavy hammer that can stun foes.", "description": "A heavy hammer that can stun foes.",
"baseAtk": 10, "baseAtk": 10,
@ -40,6 +44,7 @@
] ]
}, },
{ {
"id": "jagged_dagger",
"name": "Jagged Dagger", "name": "Jagged Dagger",
"description": "A cruel dagger that causes bleeding.", "description": "A cruel dagger that causes bleeding.",
"baseAtk": 7, "baseAtk": 7,
@ -56,6 +61,7 @@
] ]
}, },
{ {
"id": "sunderer_axe",
"name": "Sunderer Axe", "name": "Sunderer Axe",
"description": "An axe that exposes enemy weaknesses.", "description": "An axe that exposes enemy weaknesses.",
"baseAtk": 11, "baseAtk": 11,
@ -73,6 +79,7 @@
], ],
"armors": [ "armors": [
{ {
"id": "torn_tunic",
"name": "Torn Tunic", "name": "Torn Tunic",
"description": "Offers minimal protection.", "description": "Offers minimal protection.",
"baseHp": 10, "baseHp": 10,
@ -81,6 +88,7 @@
"image": "assets/images/items/torn_tunic.png" "image": "assets/images/items/torn_tunic.png"
}, },
{ {
"id": "leather_vest",
"name": "Leather Vest", "name": "Leather Vest",
"description": "Light and flexible.", "description": "Light and flexible.",
"baseHp": 30, "baseHp": 30,
@ -89,6 +97,7 @@
"image": "assets/images/items/leather_vest.png" "image": "assets/images/items/leather_vest.png"
}, },
{ {
"id": "chainmail",
"name": "Chainmail", "name": "Chainmail",
"description": "Reliable protection against cuts.", "description": "Reliable protection against cuts.",
"baseHp": 60, "baseHp": 60,
@ -99,6 +108,7 @@
], ],
"shields": [ "shields": [
{ {
"id": "pot_lid",
"name": "Pot Lid", "name": "Pot Lid",
"description": "It was used for cooking.", "description": "It was used for cooking.",
"baseArmor": 1, "baseArmor": 1,
@ -107,6 +117,7 @@
"image": "assets/images/items/pot_lid.png" "image": "assets/images/items/pot_lid.png"
}, },
{ {
"id": "wooden_shield",
"name": "Wooden Shield", "name": "Wooden Shield",
"description": "Sturdy oak wood.", "description": "Sturdy oak wood.",
"baseArmor": 3, "baseArmor": 3,
@ -115,6 +126,7 @@
"image": "assets/images/items/wooden_shield.png" "image": "assets/images/items/wooden_shield.png"
}, },
{ {
"id": "kite_shield",
"name": "Kite Shield", "name": "Kite Shield",
"description": "Used by knights.", "description": "Used by knights.",
"baseArmor": 6, "baseArmor": 6,
@ -123,6 +135,7 @@
"image": "assets/images/items/kite_shield.png" "image": "assets/images/items/kite_shield.png"
}, },
{ {
"id": "cursed_shield",
"name": "Cursed Shield", "name": "Cursed Shield",
"description": "A shield that prevents the wielder from defending themselves.", "description": "A shield that prevents the wielder from defending themselves.",
"baseArmor": 5, "baseArmor": 5,
@ -140,6 +153,7 @@
], ],
"accessories": [ "accessories": [
{ {
"id": "old_ring",
"name": "Old Ring", "name": "Old Ring",
"description": "A tarnished ring.", "description": "A tarnished ring.",
"baseAtk": 1, "baseAtk": 1,
@ -149,6 +163,7 @@
"image": "assets/images/items/old_ring.png" "image": "assets/images/items/old_ring.png"
}, },
{ {
"id": "copper_ring",
"name": "Copper Ring", "name": "Copper Ring",
"description": "A simple ring", "description": "A simple ring",
"baseAtk": 1, "baseAtk": 1,
@ -158,6 +173,7 @@
"image": "assets/images/items/copper_ring.png" "image": "assets/images/items/copper_ring.png"
}, },
{ {
"id": "ruby_amulet",
"name": "Ruby Amulet", "name": "Ruby Amulet",
"description": "Glows with a faint red light.", "description": "Glows with a faint red light.",
"baseAtk": 3, "baseAtk": 3,
@ -167,6 +183,7 @@
"image": "assets/images/items/ruby_amulet.png" "image": "assets/images/items/ruby_amulet.png"
}, },
{ {
"id": "heros_badge",
"name": "Hero's Badge", "name": "Hero's Badge",
"description": "A badge of honor.", "description": "A badge of honor.",
"baseAtk": 5, "baseAtk": 5,

11
assets/data/players.json Normal file
View File

@ -0,0 +1,11 @@
[
{
"id": "warrior",
"name": "Warrior",
"description": "A balanced fighter with a sword and shield. Great for beginners.",
"baseHp": 50,
"baseAtk": 5,
"baseDefense": 5,
"image": "assets/images/players/warrior.png"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@ -2,12 +2,15 @@ import 'dart:convert';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../model/entity.dart'; import '../model/entity.dart';
import 'item_table.dart';
class EnemyTemplate { class EnemyTemplate {
final String name; final String name;
final int baseHp; final int baseHp;
final int baseAtk; final int baseAtk;
final int baseDefense; final int baseDefense;
final String? image; final String? image;
final List<String> equipmentIds;
const EnemyTemplate({ const EnemyTemplate({
required this.name, required this.name,
@ -15,6 +18,7 @@ class EnemyTemplate {
required this.baseAtk, required this.baseAtk,
required this.baseDefense, required this.baseDefense,
this.image, this.image,
this.equipmentIds = const [],
}); });
factory EnemyTemplate.fromJson(Map<String, dynamic> json) { factory EnemyTemplate.fromJson(Map<String, dynamic> json) {
@ -24,6 +28,7 @@ class EnemyTemplate {
baseAtk: json['baseAtk'] ?? 1, baseAtk: json['baseAtk'] ?? 1,
baseDefense: json['baseDefense'] ?? 0, baseDefense: json['baseDefense'] ?? 0,
image: json['image'], image: json['image'],
equipmentIds: (json['equipment'] as List<dynamic>?)?.cast<String>() ?? [],
); );
} }
@ -33,7 +38,7 @@ class EnemyTemplate {
int scaledAtk = baseAtk + (stage - 1); int scaledAtk = baseAtk + (stage - 1);
int scaledDefense = baseDefense + (stage ~/ 5); // +1 defense every 5 stages int scaledDefense = baseDefense + (stage ~/ 5); // +1 defense every 5 stages
return Character( final character = Character(
name: name, name: name,
maxHp: scaledHp, maxHp: scaledHp,
atk: scaledAtk, atk: scaledAtk,
@ -41,6 +46,20 @@ class EnemyTemplate {
armor: 0, armor: 0,
image: image, image: image,
); );
// Equip items
for (final itemId in equipmentIds) {
final itemTemplate = ItemTable.get(itemId);
if (itemTemplate != null) {
// Create item scaled to stage (optional, currently stage 1)
// Enemies might get stronger items at higher stages
final item = itemTemplate.createItem(stage: stage);
character.addToInventory(item);
character.equip(item);
}
}
return character;
} }
} }

View File

@ -4,6 +4,7 @@ import '../model/item.dart';
import '../enums.dart'; import '../enums.dart';
class ItemTemplate { class ItemTemplate {
final String id;
final String name; final String name;
final String description; final String description;
final int baseAtk; final int baseAtk;
@ -15,6 +16,7 @@ class ItemTemplate {
final String? image; final String? image;
const ItemTemplate({ const ItemTemplate({
required this.id,
required this.name, required this.name,
required this.description, required this.description,
this.baseAtk = 0, this.baseAtk = 0,
@ -28,6 +30,9 @@ class ItemTemplate {
factory ItemTemplate.fromJson(Map<String, dynamic> json) { factory ItemTemplate.fromJson(Map<String, dynamic> json) {
return ItemTemplate( return ItemTemplate(
id:
json['id'] ??
json['name'], // Fallback to name if id is missing (for backward compatibility during dev)
name: json['name'], name: json['name'],
description: json['description'], description: json['description'],
baseAtk: json['baseAtk'] ?? 0, baseAtk: json['baseAtk'] ?? 0,
@ -60,6 +65,7 @@ class ItemTemplate {
} }
return Item( return Item(
id: id,
name: "$name${stage > 1 ? ' +${stage - 1}' : ''}", // Append +1, +2 etc. name: "$name${stage > 1 ? ' +${stage - 1}' : ''}", // Append +1, +2 etc.
description: description, description: description,
atkBonus: scaledAtk, atkBonus: scaledAtk,
@ -105,4 +111,12 @@ class ItemTable {
...shields, ...shields,
...accessories, ...accessories,
]; ];
static ItemTemplate? get(String id) {
try {
return allItems.firstWhere((item) => item.id == id);
} catch (e) {
return null;
}
}
} }

View File

@ -0,0 +1,66 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import '../model/entity.dart';
class PlayerTemplate {
final String id;
final String name;
final String description;
final int baseHp;
final int baseAtk;
final int baseDefense;
final String? image;
const PlayerTemplate({
required this.id,
required this.name,
required this.description,
required this.baseHp,
required this.baseAtk,
required this.baseDefense,
this.image,
});
factory PlayerTemplate.fromJson(Map<String, dynamic> json) {
return PlayerTemplate(
id: json['id'],
name: json['name'],
description: json['description'],
baseHp: json['baseHp'],
baseAtk: json['baseAtk'],
baseDefense: json['baseDefense'],
image: json['image'],
);
}
Character createCharacter() {
return Character(
name: name,
maxHp: baseHp,
atk: baseAtk,
baseDefense: baseDefense,
armor: 0,
);
}
}
class PlayerTable {
static List<PlayerTemplate> players = [];
static Future<void> load() async {
final String jsonString = await rootBundle.loadString(
'assets/data/players.json',
);
final List<dynamic> data = jsonDecode(jsonString);
players = data.map((e) => PlayerTemplate.fromJson(e)).toList();
}
static PlayerTemplate? get(String id) {
try {
return players.firstWhere((player) => player.id == id);
} catch (e) {
return null;
}
}
}

View File

@ -36,6 +36,7 @@ class ItemEffect {
} }
class Item { class Item {
final String id; // Unique identifier
final String name; final String name;
final String description; final String description;
final int atkBonus; final int atkBonus;
@ -47,6 +48,7 @@ class Item {
final String? image; // New: Image path final String? image; // New: Image path
Item({ Item({
required this.id,
required this.name, required this.name,
required this.description, required this.description,
required this.atkBonus, required this.atkBonus,

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'game/data/item_table.dart'; import 'game/data/item_table.dart';
import 'game/data/enemy_table.dart'; import 'game/data/enemy_table.dart';
import 'game/data/player_table.dart';
import 'providers/battle_provider.dart'; import 'providers/battle_provider.dart';
import 'screens/main_menu_screen.dart'; import 'screens/main_menu_screen.dart';
@ -9,6 +10,7 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await ItemTable.load(); await ItemTable.load();
await EnemyTable.load(); await EnemyTable.load();
await PlayerTable.load();
runApp(const MyApp()); runApp(const MyApp());
} }

View File

@ -7,7 +7,9 @@ import '../game/model/item.dart';
import '../game/model/status_effect.dart'; import '../game/model/status_effect.dart';
import '../game/model/stage.dart'; import '../game/model/stage.dart';
import '../game/data/item_table.dart'; import '../game/data/item_table.dart';
import '../game/data/enemy_table.dart'; import '../game/data/enemy_table.dart';
import '../game/data/player_table.dart';
import '../utils/game_math.dart'; import '../utils/game_math.dart';
import '../game/enums.dart'; import '../game/enums.dart';
import '../game/model/damage_event.dart'; // DamageEvent import import '../game/model/damage_event.dart'; // DamageEvent import
@ -70,16 +72,24 @@ class BattleProvider with ChangeNotifier {
void initializeBattle() { void initializeBattle() {
stage = 1; stage = 1;
turnCount = 1; turnCount = 1;
player = Character( // Load player from PlayerTable
name: "Player", final playerTemplate = PlayerTable.get("warrior");
maxHp: 80, if (playerTemplate != null) {
armor: 0, player = playerTemplate.createCharacter();
atk: 5, } else {
baseDefense: 5, // Fallback if data is missing
); player = Character(
name: "Player",
maxHp: 50,
armor: 0,
atk: 5,
baseDefense: 5,
);
}
// Provide starter equipment // Provide starter equipment
final starterSword = Item( final starterSword = Item(
id: "starter_sword",
name: "Wooden Sword", name: "Wooden Sword",
description: "A basic sword", description: "A basic sword",
atkBonus: 5, atkBonus: 5,
@ -87,6 +97,7 @@ class BattleProvider with ChangeNotifier {
slot: EquipmentSlot.weapon, slot: EquipmentSlot.weapon,
); );
final starterArmor = Item( final starterArmor = Item(
id: "starter_armor",
name: "Leather Armor", name: "Leather Armor",
description: "Basic protection", description: "Basic protection",
atkBonus: 0, atkBonus: 0,
@ -94,6 +105,7 @@ class BattleProvider with ChangeNotifier {
slot: EquipmentSlot.armor, slot: EquipmentSlot.armor,
); );
final starterShield = Item( final starterShield = Item(
id: "starter_shield",
name: "Wooden Shield", name: "Wooden Shield",
description: "A small shield", description: "A small shield",
atkBonus: 0, atkBonus: 0,
@ -102,6 +114,7 @@ class BattleProvider with ChangeNotifier {
slot: EquipmentSlot.shield, slot: EquipmentSlot.shield,
); );
final starterRing = Item( final starterRing = Item(
id: "starter_ring",
name: "Copper Ring", name: "Copper Ring",
description: "A simple ring", description: "A simple ring",
atkBonus: 1, atkBonus: 1,
@ -436,6 +449,9 @@ class BattleProvider with ChangeNotifier {
_applyDamage(player, damageToHp, targetType: DamageTarget.player); _applyDamage(player, damageToHp, targetType: DamageTarget.player);
_addLog("Enemy dealt $damageToHp damage to Player HP."); _addLog("Enemy dealt $damageToHp damage to Player HP.");
} }
// Try applying status effects from enemy equipment
_tryApplyStatusEffects(enemy, player);
} else { } else {
_addLog("Enemy's ${intent.risk.name} attack missed!"); _addLog("Enemy's ${intent.risk.name} attack missed!");
_effectEventController.sink.add( _effectEventController.sink.add(

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/battle_provider.dart'; import '../providers/battle_provider.dart';
import '../game/model/entity.dart';
import '../game/enums.dart'; import '../game/enums.dart';
import '../game/model/item.dart'; import '../game/model/item.dart';
import '../game/model/damage_event.dart'; import '../game/model/damage_event.dart';
@ -9,6 +9,10 @@ import '../game/model/effect_event.dart';
import 'dart:async'; import 'dart:async';
import '../widgets/responsive_container.dart'; import '../widgets/responsive_container.dart';
import '../utils/item_utils.dart'; import '../utils/item_utils.dart';
import '../widgets/battle/character_status_card.dart';
import '../widgets/battle/battle_log_overlay.dart';
import '../widgets/battle/floating_battle_texts.dart';
import '../widgets/battle/stage_ui.dart';
class BattleScreen extends StatefulWidget { class BattleScreen extends StatefulWidget {
const BattleScreen({super.key}); const BattleScreen({super.key});
@ -18,25 +22,19 @@ class BattleScreen extends StatefulWidget {
} }
class _BattleScreenState extends State<BattleScreen> { class _BattleScreenState extends State<BattleScreen> {
final ScrollController _scrollController = ScrollController(); final List<DamageTextData> _floatingDamageTexts = [];
final List<_DamageTextData> _floatingDamageTexts = []; final List<FloatingEffectData> _floatingEffects = [];
final List<_FloatingEffectData> _floatingEffects = []; final List<FeedbackTextData> _floatingFeedbackTexts = [];
final List<_FeedbackTextData> _floatingFeedbackTexts = [];
StreamSubscription<DamageEvent>? _damageSubscription; StreamSubscription<DamageEvent>? _damageSubscription;
StreamSubscription<EffectEvent>? _effectSubscription; StreamSubscription<EffectEvent>? _effectSubscription;
final GlobalKey _playerKey = GlobalKey(); final GlobalKey _playerKey = GlobalKey();
final GlobalKey _enemyKey = GlobalKey(); final GlobalKey _enemyKey = GlobalKey();
final GlobalKey _stackKey = GlobalKey(); final GlobalKey _stackKey = GlobalKey();
bool _showLogs = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
});
final battleProvider = context.read<BattleProvider>(); final battleProvider = context.read<BattleProvider>();
_damageSubscription = battleProvider.damageStream.listen( _damageSubscription = battleProvider.damageStream.listen(
_addFloatingDamageText, _addFloatingDamageText,
@ -48,7 +46,6 @@ class _BattleScreenState extends State<BattleScreen> {
@override @override
void dispose() { void dispose() {
_scrollController.dispose();
_damageSubscription?.cancel(); _damageSubscription?.cancel();
_effectSubscription?.cancel(); _effectSubscription?.cancel();
super.dispose(); super.dispose();
@ -82,12 +79,12 @@ class _BattleScreenState extends State<BattleScreen> {
setState(() { setState(() {
_floatingDamageTexts.add( _floatingDamageTexts.add(
_DamageTextData( DamageTextData(
id: id, id: id,
widget: Positioned( widget: Positioned(
left: position.dx, left: position.dx,
top: position.dy, top: position.dy,
child: _FloatingDamageText( child: FloatingDamageText(
key: ValueKey(id), key: ValueKey(id),
damage: event.damage.toString(), damage: event.damage.toString(),
color: event.color, color: event.color,
@ -153,12 +150,12 @@ class _BattleScreenState extends State<BattleScreen> {
final String id = UniqueKey().toString(); final String id = UniqueKey().toString();
setState(() { setState(() {
_floatingFeedbackTexts.add( _floatingFeedbackTexts.add(
_FeedbackTextData( FeedbackTextData(
id: id, id: id,
widget: Positioned( widget: Positioned(
left: position.dx, left: position.dx,
top: position.dy, top: position.dy,
child: _FloatingFeedbackText( child: FloatingFeedbackText(
key: ValueKey(id), key: ValueKey(id),
feedback: feedbackText, feedback: feedbackText,
color: feedbackColor, color: feedbackColor,
@ -213,12 +210,12 @@ class _BattleScreenState extends State<BattleScreen> {
setState(() { setState(() {
_floatingEffects.add( _floatingEffects.add(
_FloatingEffectData( FloatingEffectData(
id: id, id: id,
widget: Positioned( widget: Positioned(
left: position.dx, left: position.dx,
top: position.dy, top: position.dy,
child: _FloatingEffect( child: FloatingEffect(
key: ValueKey(id), key: ValueKey(id),
icon: icon, icon: icon,
color: color, color: color,
@ -295,13 +292,6 @@ class _BattleScreenState extends State<BattleScreen> {
onPressed: () { onPressed: () {
context.read<BattleProvider>().playerAction(actionType, risk); context.read<BattleProvider>().playerAction(actionType, risk);
Navigator.pop(context); Navigator.pop(context);
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
});
}, },
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -329,15 +319,18 @@ class _BattleScreenState extends State<BattleScreen> {
child: Consumer<BattleProvider>( child: Consumer<BattleProvider>(
builder: (context, battleProvider, child) { builder: (context, battleProvider, child) {
if (battleProvider.currentStage.type == StageType.shop) { if (battleProvider.currentStage.type == StageType.shop) {
return _buildShopUI(context, battleProvider); return ShopUI(battleProvider: battleProvider);
} else if (battleProvider.currentStage.type == StageType.rest) { } else if (battleProvider.currentStage.type == StageType.rest) {
return _buildRestUI(context, battleProvider); return RestUI(battleProvider: battleProvider);
} }
return Stack( return Stack(
key: _stackKey, key: _stackKey,
children: [ children: [
// 1. Background (Black)
Container(color: Colors.black87), Container(color: Colors.black87),
// 2. Battle Content (Top Bar + Characters)
Column( Column(
children: [ children: [
// Top Bar // Top Bar
@ -346,125 +339,135 @@ class _BattleScreenState extends State<BattleScreen> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Flexible(
"Stage ${battleProvider.stage}", child: FittedBox(
style: const TextStyle( fit: BoxFit.scaleDown,
color: Colors.white, child: Text(
fontSize: 18, "Stage ${battleProvider.stage}",
fontWeight: FontWeight.bold, style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
), ),
), ),
Text( Flexible(
"Turn ${battleProvider.turnCount}", child: FittedBox(
style: const TextStyle( fit: BoxFit.scaleDown,
color: Colors.white, child: Text(
fontSize: 18, "Turn ${battleProvider.turnCount}",
style: const TextStyle(
color: Colors.white,
fontSize: 18,
),
),
), ),
), ),
], ],
), ),
), ),
// Battle Area // Battle Area (Characters) - Expanded to fill available space
Expanded( Expanded(
child: Padding( child: Padding(
// padding: const EdgeInsets.symmetric(horizontal: 40.0), padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.all(70.0), child: Stack(
child: Column(
children: [ children: [
// ( ) // Enemy (Top Right)
Expanded( Positioned(
child: Align( top: 0,
alignment: Alignment.topRight, right: 0,
child: _buildCharacterStatus( child: CharacterStatusCard(
battleProvider.enemy, character: battleProvider.enemy,
isPlayer: false, isPlayer: false,
isTurn: !battleProvider.isPlayerTurn, isTurn: !battleProvider.isPlayerTurn,
key: _enemyKey, key: _enemyKey,
),
), ),
), ),
// ( ) // Player (Bottom Left)
Expanded( Positioned(
child: Align( bottom: 80, // Space for FABs
alignment: Alignment.bottomLeft, left: 0,
child: _buildCharacterStatus( child: CharacterStatusCard(
battleProvider.player, character: battleProvider.player,
isPlayer: true, isPlayer: true,
isTurn: battleProvider.isPlayerTurn, isTurn: battleProvider.isPlayerTurn,
key: _playerKey, key: _playerKey,
),
), ),
), ),
], ],
), ),
), ),
), ),
// Action Buttons
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
if (battleProvider.logs.isNotEmpty)
Container(
height: 60,
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: ListView.builder(
reverse: true,
itemCount: battleProvider.logs.length,
itemBuilder: (context, index) {
final logIndex =
battleProvider.logs.length - 1 - index;
return Text(
battleProvider.logs[logIndex],
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
),
);
},
),
),
const SizedBox(height: 16),
Card(
color: Colors.grey[900],
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildActionButton(
context,
"ATTACK",
ActionType.attack,
battleProvider.isPlayerTurn &&
!battleProvider.player.isDead &&
!battleProvider.enemy.isDead &&
!battleProvider.showRewardPopup,
),
_buildActionButton(
context,
"DEFEND",
ActionType.defend,
battleProvider.isPlayerTurn &&
!battleProvider.player.isDead &&
!battleProvider.enemy.isDead &&
!battleProvider.showRewardPopup,
),
],
),
),
),
],
),
),
], ],
), ),
// 3. Logs Overlay
if (_showLogs && battleProvider.logs.isNotEmpty)
Positioned(
top: 60,
left: 16,
right: 16,
height: 150,
child: BattleLogOverlay(logs: battleProvider.logs),
),
// 4. Floating Action Buttons (Bottom Right)
Positioned(
bottom: 20,
right: 20,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildFloatingActionButton(
context,
"ATK",
Icons.whatshot,
Colors.redAccent,
ActionType.attack,
battleProvider.isPlayerTurn &&
!battleProvider.player.isDead &&
!battleProvider.enemy.isDead &&
!battleProvider.showRewardPopup,
),
const SizedBox(height: 16),
_buildFloatingActionButton(
context,
"DEF",
Icons.shield,
Colors.blueAccent,
ActionType.defend,
battleProvider.isPlayerTurn &&
!battleProvider.player.isDead &&
!battleProvider.enemy.isDead &&
!battleProvider.showRewardPopup,
),
],
),
),
// 5. Log Toggle Button (Bottom Left)
Positioned(
bottom: 20,
left: 20,
child: FloatingActionButton(
heroTag: "logToggle",
mini: true,
backgroundColor: Colors.grey[800],
onPressed: () {
setState(() {
_showLogs = !_showLogs;
});
},
child: Icon(
_showLogs ? Icons.visibility_off : Icons.visibility,
color: Colors.white,
),
),
),
// Reward Popup
if (battleProvider.showRewardPopup) if (battleProvider.showRewardPopup)
Container( Container(
color: Colors.black54, color: Colors.black54,
@ -519,9 +522,11 @@ class _BattleScreenState extends State<BattleScreen> {
), ),
), ),
), ),
// Floating Effects
..._floatingDamageTexts.map((e) => e.widget), ..._floatingDamageTexts.map((e) => e.widget),
..._floatingEffects.map((e) => e.widget), ..._floatingEffects.map((e) => e.widget),
..._floatingFeedbackTexts.map((e) => e.widget), // ..._floatingFeedbackTexts.map((e) => e.widget),
], ],
); );
}, },
@ -529,49 +534,6 @@ class _BattleScreenState extends State<BattleScreen> {
); );
} }
Widget _buildShopUI(BuildContext context, BattleProvider battleProvider) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.store, size: 64, color: Colors.amber),
const SizedBox(height: 16),
const Text("Merchant Shop", style: TextStyle(fontSize: 24)),
const SizedBox(height: 8),
const Text("Buying/Selling feature coming soon!"),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () => battleProvider.proceedToNextStage(),
child: const Text("Leave Shop"),
),
],
),
);
}
Widget _buildRestUI(BuildContext context, BattleProvider battleProvider) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.local_hotel, size: 64, color: Colors.blue),
const SizedBox(height: 16),
const Text("Rest Area", style: TextStyle(fontSize: 24)),
const SizedBox(height: 8),
const Text("Take a breath and heal."),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () {
battleProvider.player.heal(20);
battleProvider.proceedToNextStage();
},
child: const Text("Rest & Leave (+20 HP)"),
),
],
),
);
}
Widget _buildItemStatText(Item item) { Widget _buildItemStatText(Item item) {
List<String> stats = []; List<String> stats = [];
if (item.atkBonus > 0) stats.add("+${item.atkBonus} ATK"); if (item.atkBonus > 0) stats.add("+${item.atkBonus} ATK");
@ -605,435 +567,21 @@ class _BattleScreenState extends State<BattleScreen> {
); );
} }
Widget _buildCharacterStatus( Widget _buildFloatingActionButton(
Character character, {
bool isPlayer = false,
bool isTurn = false,
Key? key,
}) {
return Column(
key: key,
children: [
Text("Armor: ${character.armor}"),
Text(
"${character.name}: HP ${character.hp}/${character.totalMaxHp}",
style: TextStyle(
color: character.isDead ? Colors.red : Colors.white,
fontWeight: FontWeight.bold,
),
),
SizedBox(
width: 100,
child: LinearProgressIndicator(
value: character.totalMaxHp > 0
? character.hp / character.totalMaxHp
: 0,
color: !isPlayer ? Colors.red : Colors.green,
backgroundColor: Colors.grey,
),
),
if (character.statusEffects.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Wrap(
spacing: 4.0,
children: character.statusEffects.map((effect) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.deepOrange,
borderRadius: BorderRadius.circular(4),
),
child: Text(
"${effect.type.name.toUpperCase()} (${effect.duration})",
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
);
}).toList(),
),
),
Text("ATK: ${character.totalAtk}"),
Text("DEF: ${character.totalDefense}"),
// /
Container(
width: 100, //
height: 100, //
decoration: BoxDecoration(
color: isPlayer
? Colors.lightBlue
: Colors.deepOrange, // /
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: isPlayer
? const Icon(
Icons.person,
size: 60,
color: Colors.white,
) //
: const Icon(
Icons.psychology,
size: 60,
color: Colors.white,
), // ( )
),
),
const SizedBox(height: 8), //
if (!isPlayer)
Consumer<BattleProvider>(
builder: (context, provider, child) {
if (provider.currentEnemyIntent != null && !character.isDead) {
final intent = provider.currentEnemyIntent!;
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Container(
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.redAccent),
),
child: Column(
children: [
Text(
"INTENT",
style: TextStyle(
color: Colors.redAccent,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
intent.type == EnemyActionType.attack
? Icons.flash_on
: Icons.shield,
color: Colors.yellow,
size: 16,
),
const SizedBox(width: 4),
Text(
intent.description,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
],
),
],
),
),
);
}
return const SizedBox.shrink();
},
),
],
);
}
Widget _buildActionButton(
BuildContext context, BuildContext context,
String text, String label,
IconData icon,
Color color,
ActionType actionType, ActionType actionType,
bool isEnabled, bool isEnabled,
) { ) {
return ElevatedButton( return FloatingActionButton(
heroTag: label,
onPressed: isEnabled onPressed: isEnabled
? () => _showRiskLevelSelection(context, actionType) ? () => _showRiskLevelSelection(context, actionType)
: null, : null,
style: ElevatedButton.styleFrom( backgroundColor: isEnabled ? color : Colors.grey,
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), child: Icon(icon),
backgroundColor: Colors.blueGrey,
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
child: Text(text),
); );
} }
} }
class _FloatingDamageText extends StatefulWidget {
final String damage;
final Color color;
final VoidCallback onRemove;
const _FloatingDamageText({
Key? key,
required this.damage,
required this.color,
required this.onRemove,
}) : super(key: key);
@override
__FloatingDamageTextState createState() => __FloatingDamageTextState();
}
class __FloatingDamageTextState extends State<_FloatingDamageText>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_offsetAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.0),
end: const Offset(0.0, -1.5),
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.5, 1.0, curve: Curves.easeOut),
),
);
_controller.forward().then((_) {
if (mounted) {
widget.onRemove();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return FractionalTranslation(
translation: _offsetAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: Material(
color: Colors.transparent,
child: Text(
widget.damage,
style: TextStyle(
color: widget.color,
fontSize: 20,
fontWeight: FontWeight.bold,
shadows: const [
Shadow(
blurRadius: 2.0,
color: Colors.black,
offset: Offset(1.0, 1.0),
),
],
),
),
),
),
);
},
);
}
}
class _DamageTextData {
final String id;
final Widget widget;
_DamageTextData({required this.id, required this.widget});
}
class _FloatingEffect extends StatefulWidget {
final IconData icon;
final Color color;
final double size;
final VoidCallback onRemove;
const _FloatingEffect({
Key? key,
required this.icon,
required this.color,
required this.size,
required this.onRemove,
}) : super(key: key);
@override
__FloatingEffectState createState() => __FloatingEffectState();
}
class __FloatingEffectState extends State<_FloatingEffect>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 0.5,
end: 1.5,
).animate(CurvedAnimation(parent: _controller, curve: Curves.elasticOut));
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.5, 1.0, curve: Curves.easeOut),
),
);
_controller.forward().then((_) {
if (mounted) {
widget.onRemove();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: Icon(widget.icon, color: widget.color, size: widget.size),
),
);
},
);
}
}
class _FloatingEffectData {
final String id;
final Widget widget;
_FloatingEffectData({required this.id, required this.widget});
}
// _FloatingFeedbackText
class _FloatingFeedbackText extends StatefulWidget {
final String feedback;
final Color color;
final VoidCallback onRemove;
const _FloatingFeedbackText({
Key? key,
required this.feedback,
required this.color,
required this.onRemove,
}) : super(key: key);
@override
__FloatingFeedbackTextState createState() => __FloatingFeedbackTextState();
}
class __FloatingFeedbackTextState extends State<_FloatingFeedbackText>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_offsetAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.0),
end: const Offset(0.0, -1.5),
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.5, 1.0, curve: Curves.easeOut),
),
);
_controller.forward().then((_) {
if (mounted) {
widget.onRemove();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return FractionalTranslation(
translation: _offsetAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: Material(
color: Colors.transparent,
child: Text(
widget.feedback,
style: TextStyle(
color: widget.color,
fontSize: 20,
fontWeight: FontWeight.bold,
shadows: const [
Shadow(
blurRadius: 2.0,
color: Colors.black,
offset: Offset(1.0, 1.0),
),
],
),
),
),
),
);
},
);
}
}
class _FeedbackTextData {
final String id;
final Widget widget;
_FeedbackTextData({required this.id, required this.widget});
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/battle_provider.dart'; import '../providers/battle_provider.dart';
import '../game/data/player_table.dart';
import 'main_wrapper.dart'; import 'main_wrapper.dart';
import '../widgets/responsive_container.dart'; import '../widgets/responsive_container.dart';
@ -9,6 +10,15 @@ class CharacterSelectionScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Fetch Warrior data
final warrior = PlayerTable.get("warrior");
if (warrior == null) {
return const Scaffold(
body: Center(child: Text("Error: Player data not found")),
);
}
return Scaffold( return Scaffold(
backgroundColor: Colors.black, // Outer background backgroundColor: Colors.black, // Outer background
body: Center( body: Center(
@ -51,37 +61,43 @@ class CharacterSelectionScreen extends StatelessWidget {
color: Colors.blue, color: Colors.blue,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(
"Warrior", warrior.name,
style: TextStyle( style: const TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white, color: Colors.white,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( Text(
"A balanced fighter with a sword and shield. Great for beginners.", warrior.description,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey), style: const TextStyle(color: Colors.grey),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Divider(), const Divider(),
const SizedBox(height: 8), const SizedBox(height: 8),
const Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
Text( Text(
"HP: 80", "HP: ${warrior.baseHp}",
style: TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(
fontWeight: FontWeight.bold,
),
), ),
Text( Text(
"ATK: 5", "ATK: ${warrior.baseAtk}",
style: TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(
fontWeight: FontWeight.bold,
),
), ),
Text( Text(
"DEF: 5", "DEF: ${warrior.baseDefense}",
style: TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(
fontWeight: FontWeight.bold,
),
), ),
], ],
), ),

View File

@ -88,41 +88,73 @@ class InventoryScreen extends StatelessWidget {
color: item != null color: item != null
? Colors.blueGrey[600] ? Colors.blueGrey[600]
: Colors.grey[800], : Colors.grey[800],
child: Padding( child: Stack(
padding: const EdgeInsets.all(8.0), children: [
child: Column( // Slot Name (Top Right)
children: [ Positioned(
Text( right: 4,
top: 4,
child: Text(
slot.name.toUpperCase(), slot.name.toUpperCase(),
style: const TextStyle( style: const TextStyle(
fontSize: 10, fontSize: 8,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.grey, color: Colors.white30,
), ),
), ),
const SizedBox(height: 4), ),
Icon( // Faded Icon (Top Left)
ItemUtils.getIcon(slot), Positioned(
size: 24, left: 4,
color: item != null top: 4,
? ItemUtils.getColor(slot) child: Opacity(
: Colors.grey, opacity: item != null ? 0.2 : 0.1,
), child: Icon(
const SizedBox(height: 4), ItemUtils.getIcon(slot),
Text( size: 40,
item?.name ?? "Empty",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: item != null color: item != null
? Colors.white ? ItemUtils.getColor(slot)
: Colors.grey, : Colors.grey,
), ),
overflow: TextOverflow.ellipsis,
), ),
if (item != null) _buildItemStatText(item), ),
], // Content
), Center(
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
const SizedBox(
height: 12,
), // Spacing for top elements
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
item?.name ?? "Empty",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: item != null
? Colors.white
: Colors.grey,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (item != null)
FittedBox(
fit: BoxFit.scaleDown,
child: _buildItemStatText(item),
),
],
),
),
),
],
), ),
), ),
), ),
@ -169,24 +201,49 @@ class InventoryScreen extends StatelessWidget {
}, },
child: Card( child: Card(
color: Colors.blueGrey[700], color: Colors.blueGrey[700],
child: Column( child: Stack(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( // Faded Icon in Top-Left
ItemUtils.getIcon(item.slot), Positioned(
size: 32, left: 4,
color: ItemUtils.getColor(item.slot), top: 4,
), child: Opacity(
Padding( opacity: 0.2,
padding: const EdgeInsets.all(4.0), child: Icon(
child: Text( ItemUtils.getIcon(item.slot),
item.name, size: 40,
textAlign: TextAlign.center, color: ItemUtils.getColor(item.slot),
style: const TextStyle(fontSize: 10), ),
overflow: TextOverflow.ellipsis, ),
),
// Centered Content
Center(
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
item.name,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
FittedBox(
fit: BoxFit.scaleDown,
child: _buildItemStatText(item),
),
],
),
), ),
), ),
_buildItemStatText(item),
], ],
), ),
), ),

View File

@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
class BattleLogOverlay extends StatelessWidget {
final List<String> logs;
const BattleLogOverlay({super.key, required this.logs});
@override
Widget build(BuildContext context) {
if (logs.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: ListView.builder(
reverse: true,
itemCount: logs.length,
itemBuilder: (context, index) {
final logIndex = logs.length - 1 - index;
return Text(
logs[logIndex],
style: const TextStyle(color: Colors.white70, fontSize: 12),
);
},
),
);
}
}

View File

@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../game/model/entity.dart';
import '../../game/enums.dart';
import '../../providers/battle_provider.dart';
class CharacterStatusCard extends StatelessWidget {
final Character character;
final bool isPlayer;
final bool isTurn;
const CharacterStatusCard({
super.key,
required this.character,
this.isPlayer = false,
this.isTurn = false,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
"Armor: ${character.armor}",
style: const TextStyle(color: Colors.white),
),
),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
"${character.name}: HP ${character.hp}/${character.totalMaxHp}",
style: TextStyle(
color: character.isDead ? Colors.red : Colors.white,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(
width: 100,
child: LinearProgressIndicator(
value: character.totalMaxHp > 0
? character.hp / character.totalMaxHp
: 0,
color: !isPlayer ? Colors.red : Colors.green,
backgroundColor: Colors.grey,
),
),
if (character.statusEffects.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Wrap(
spacing: 4.0,
children: character.statusEffects.map((effect) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.deepOrange,
borderRadius: BorderRadius.circular(4),
),
child: Text(
"${effect.type.name.toUpperCase()} (${effect.duration})",
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
);
}).toList(),
),
),
Text("ATK: ${character.totalAtk}"),
Text("DEF: ${character.totalDefense}"),
// /
Container(
width: 100, //
height: 100, //
decoration: BoxDecoration(
color: isPlayer
? Colors.lightBlue
: Colors.deepOrange, // /
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: isPlayer
? const Icon(
Icons.person,
size: 60,
color: Colors.white,
) //
: const Icon(
Icons.psychology,
size: 60,
color: Colors.white,
), // ( )
),
),
const SizedBox(height: 8), //
if (!isPlayer)
Consumer<BattleProvider>(
builder: (context, provider, child) {
if (provider.currentEnemyIntent != null && !character.isDead) {
final intent = provider.currentEnemyIntent!;
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Container(
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.redAccent),
),
child: Column(
children: [
Text(
"INTENT",
style: TextStyle(
color: Colors.redAccent,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
intent.type == EnemyActionType.attack
? Icons.flash_on
: Icons.shield,
color: Colors.yellow,
size: 16,
),
const SizedBox(width: 4),
Text(
intent.description,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
],
),
],
),
),
);
}
return const SizedBox.shrink();
},
),
],
);
}
}

View File

@ -0,0 +1,274 @@
import 'package:flutter/material.dart';
class FloatingDamageText extends StatefulWidget {
final String damage;
final Color color;
final VoidCallback onRemove;
const FloatingDamageText({
Key? key,
required this.damage,
required this.color,
required this.onRemove,
}) : super(key: key);
@override
FloatingDamageTextState createState() => FloatingDamageTextState();
}
class FloatingDamageTextState extends State<FloatingDamageText>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_offsetAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.0),
end: const Offset(0.0, -1.5),
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.5, 1.0, curve: Curves.easeOut),
),
);
_controller.forward().then((_) {
if (mounted) {
widget.onRemove();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return FractionalTranslation(
translation: _offsetAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: Material(
color: Colors.transparent,
child: Text(
widget.damage,
style: TextStyle(
color: widget.color,
fontSize: 20,
fontWeight: FontWeight.bold,
shadows: const [
Shadow(
blurRadius: 2.0,
color: Colors.black,
offset: Offset(1.0, 1.0),
),
],
),
),
),
),
);
},
);
}
}
class DamageTextData {
final String id;
final Widget widget;
DamageTextData({required this.id, required this.widget});
}
class FloatingEffect extends StatefulWidget {
final IconData icon;
final Color color;
final double size;
final VoidCallback onRemove;
const FloatingEffect({
Key? key,
required this.icon,
required this.color,
required this.size,
required this.onRemove,
}) : super(key: key);
@override
FloatingEffectState createState() => FloatingEffectState();
}
class FloatingEffectState extends State<FloatingEffect>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 0.5,
end: 1.5,
).animate(CurvedAnimation(parent: _controller, curve: Curves.elasticOut));
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.5, 1.0, curve: Curves.easeOut),
),
);
_controller.forward().then((_) {
if (mounted) {
widget.onRemove();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: Icon(widget.icon, color: widget.color, size: widget.size),
),
);
},
);
}
}
class FloatingEffectData {
final String id;
final Widget widget;
FloatingEffectData({required this.id, required this.widget});
}
class FloatingFeedbackText extends StatefulWidget {
final String feedback;
final Color color;
final VoidCallback onRemove;
const FloatingFeedbackText({
Key? key,
required this.feedback,
required this.color,
required this.onRemove,
}) : super(key: key);
@override
FloatingFeedbackTextState createState() => FloatingFeedbackTextState();
}
class FloatingFeedbackTextState extends State<FloatingFeedbackText>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_offsetAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.0),
end: const Offset(0.0, -1.5),
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.5, 1.0, curve: Curves.easeOut),
),
);
_controller.forward().then((_) {
if (mounted) {
widget.onRemove();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return FractionalTranslation(
translation: _offsetAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: Material(
color: Colors.transparent,
child: Text(
widget.feedback,
style: TextStyle(
color: widget.color,
fontSize: 20,
fontWeight: FontWeight.bold,
shadows: const [
Shadow(
blurRadius: 2.0,
color: Colors.black,
offset: Offset(1.0, 1.0),
),
],
),
),
),
),
);
},
);
}
}
class FeedbackTextData {
final String id;
final Widget widget;
FeedbackTextData({required this.id, required this.widget});
}

View File

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../../providers/battle_provider.dart';
class ShopUI extends StatelessWidget {
final BattleProvider battleProvider;
const ShopUI({super.key, required this.battleProvider});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.store, size: 64, color: Colors.amber),
const SizedBox(height: 16),
const Text("Merchant Shop", style: TextStyle(fontSize: 24)),
const SizedBox(height: 8),
const Text("Buying/Selling feature coming soon!"),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () => battleProvider.proceedToNextStage(),
child: const Text("Leave Shop"),
),
],
),
);
}
}
class RestUI extends StatelessWidget {
final BattleProvider battleProvider;
const RestUI({super.key, required this.battleProvider});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.local_hotel, size: 64, color: Colors.blue),
const SizedBox(height: 16),
const Text("Rest Area", style: TextStyle(fontSize: 24)),
const SizedBox(height: 8),
const Text("Take a breath and heal."),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () {
battleProvider.player.heal(20);
battleProvider.proceedToNextStage();
},
child: const Text("Rest & Leave (+20 HP)"),
),
],
),
);
}
}

View File

@ -20,6 +20,7 @@
- `ResponsiveContainer` 위젯을 통해 최대 너비(600px) 및 높이(1000px) 제한. - `ResponsiveContainer` 위젯을 통해 최대 너비(600px) 및 높이(1000px) 제한.
- 웹/태블릿 환경에서도 모바일 앱처럼 중앙 정렬된 화면 제공. - 웹/태블릿 환경에서도 모바일 앱처럼 중앙 정렬된 화면 제공.
- **Battle UI Layout:** 플레이어(좌측 하단) vs 적(우측 상단) 대각선 구도 배치 및 캐릭터 임시 아이콘 적용. - **Battle UI Layout:** 플레이어(좌측 하단) vs 적(우측 상단) 대각선 구도 배치 및 캐릭터 임시 아이콘 적용.
- **Widget Refactoring:** `BattleScreen`의 주요 UI 컴포넌트(`CharacterStatusCard`, `BattleLogOverlay` 등)를 `lib/widgets/battle/`로 분리하여 모듈화.
### B. 전투 시스템 (`BattleProvider`) ### B. 전투 시스템 (`BattleProvider`)
@ -31,6 +32,9 @@
- **선제 방어 (Pre-emptive Defense):** 적이 방어를 선택하면 턴 시작 전에 즉시 방어도가 적용됨. - **선제 방어 (Pre-emptive Defense):** 적이 방어를 선택하면 턴 시작 전에 즉시 방어도가 적용됨.
- **Defense Restriction:** `DefenseForbidden` 상태 시 방어 행동 선택 불가. - **Defense Restriction:** `DefenseForbidden` 상태 시 방어 행동 선택 불가.
- **Variance Removed:** 적의 공격/방어 수치 계산 시 랜덤 분산(Variance) 제거 (고정 수치). - **Variance Removed:** 적의 공격/방어 수치 계산 시 랜덤 분산(Variance) 제거 (고정 수치).
- **적 장비 시스템 (Enemy Equipment):**
- 적에게 아이템 장착 가능 (`enemies.json`의 `equipment` 필드).
- 장착된 아이템의 스탯 및 특수 효과(상태이상 등)가 전투 시 적용됨.
- **시각 효과 (Visual Effects):** - **시각 효과 (Visual Effects):**
- **Floating Text:** 데미지 발생 시 캐릭터 위에 데미지 수치가 떠오름 (일반: 빨강, 출혈: 보라, 취약: 주황). - **Floating Text:** 데미지 발생 시 캐릭터 위에 데미지 수치가 떠오름 (일반: 빨강, 출혈: 보라, 취약: 주황).
- **Action Feedback:** 공격 빗나감(MISS - 적 위치/회색) 및 방어 실패(FAILED - 내 위치/빨강) 텍스트 오버레이. - **Action Feedback:** 공격 빗나감(MISS - 적 위치/회색) 및 방어 실패(FAILED - 내 위치/빨강) 텍스트 오버레이.
@ -39,8 +43,8 @@
### C. 데이터 주도 설계 (Data-Driven Design) ### C. 데이터 주도 설계 (Data-Driven Design)
- **JSON 데이터:** `assets/data/items.json`, `assets/data/enemies.json`. - **JSON 데이터:** `assets/data/items.json` (ID 포함), `assets/data/enemies.json` (장비 포함).
- **데이터 로더:** `ItemTable`, `EnemyTable`. - **데이터 로더:** `ItemTable` (ID 조회 지원), `EnemyTable` (장비 장착 지원).
### D. 아이템 및 경제 (`Item`, `Inventory`) ### D. 아이템 및 경제 (`Item`, `Inventory`)
@ -65,13 +69,15 @@
- **Streams:** `damageStream`, `effectStream`을 통해 UI(`BattleScreen`)에 비동기 이벤트 전달. - **Streams:** `damageStream`, `effectStream`을 통해 UI(`BattleScreen`)에 비동기 이벤트 전달.
- **`lib/game/enums.dart`:** 프로젝트 전반의 Enum 통합 관리 (`ActionType`, `RiskLevel`, `StageType` 등). - **`lib/game/enums.dart`:** 프로젝트 전반의 Enum 통합 관리 (`ActionType`, `RiskLevel`, `StageType` 등).
- **`lib/utils/item_utils.dart`:** 아이템 타입별 아이콘 및 색상 로직 중앙화. - **`lib/utils/item_utils.dart`:** 아이템 타입별 아이콘 및 색상 로직 중앙화.
- **`lib/widgets/battle/`:** `BattleScreen`에서 분리된 재사용 가능한 위젯들 (`CharacterStatusCard`, `BattleLogOverlay`, `FloatingBattleTexts`, `StageUI`).
- **`lib/widgets/responsive_container.dart`:** 반응형 레이아웃 컨테이너. - **`lib/widgets/responsive_container.dart`:** 반응형 레이아웃 컨테이너.
- **`lib/game/model/`:** - **`lib/game/model/`:**
- `damage_event.dart`, `effect_event.dart`: 이벤트 모델. - `damage_event.dart`, `effect_event.dart`: 이벤트 모델.
- `entity.dart`: `Character` (Player/Enemy). - `entity.dart`: `Character` (Player/Enemy).
- `item.dart`: `Item`. - `item.dart`: `Item` (ID 필드 포함).
- **`lib/screens/battle_screen.dart`:** - **`lib/screens/battle_screen.dart`:**
- `StreamSubscription`을 통해 이펙트 이벤트 수신 및 `Overlay` 애니메이션 렌더링. - `StreamSubscription`을 통해 이펙트 이벤트 수신 및 `Overlay` 애니메이션 렌더링.
- `Stack``Positioned` 기반의 정교한 레이아웃.
## 4. 작업 컨벤션 (Working Conventions) ## 4. 작업 컨벤션 (Working Conventions)

View File

@ -0,0 +1,28 @@
# 배틀 화면 위젯 리팩토링
## 목표
UI 컴포넌트를 별도의 위젯으로 추출하여 `BattleScreen`의 코드 유지보수성, 가독성 및 구조를 개선합니다.
## 변경 사항
1. **디렉토리 구조:**
- `lib/widgets/battle/` 디렉토리를 생성했습니다.
2. **추출된 위젯:**
- `CharacterStatusCard` (`lib/widgets/battle/character_status_card.dart`): 캐릭터 스탯(HP, 방어도, 공격력, 방어력), 체력 바, 상태 효과 및 의도를 표시합니다.
- `BattleLogOverlay` (`lib/widgets/battle/battle_log_overlay.dart`): 스크롤 가능한 배틀 로그 목록을 표시합니다.
- `FloatingBattleTexts` (`lib/widgets/battle/floating_battle_texts.dart`): 애니메이션 시각 효과를 위한 `FloatingDamageText`, `FloatingEffect`, `FloatingFeedbackText`를 포함합니다.
- `StageUI` (`lib/widgets/battle/stage_ui.dart`): 비전투 스테이지를 위한 `ShopUI``RestUI`를 포함합니다.
3. **BattleScreen 업데이트:**
- 추출된 위젯을 임포트하고 사용하도록 `lib/screens/battle_screen.dart`를 리팩토링했습니다.
- 인라인 위젯 빌드 메서드(`_buildCharacterStatus`, `_buildShopUI`, `_buildRestUI` 등)와 내부 클래스(`_FloatingDamageText` 등)를 제거했습니다.
## 이점
- **복잡도 감소:** `BattleScreen`은 이제 레이아웃과 상태 관리에 집중합니다.
- **재사용성:** 위젯은 필요에 따라 앱의 다른 부분에서 재사용할 수 있습니다.
- **유지보수성:** 특정 UI 요소를 찾고 수정하기가 더 쉬워졌습니다.

View File

@ -0,0 +1,27 @@
# 적 장비 시스템 구현 (Enemy Equipment System)
## 목표
적에게 아이템을 장착시켜 전투의 다양성을 높이고, 아이템 데이터 구조를 개선합니다.
## 주요 변경 사항
### 1. 데이터 구조 개선
- **`assets/data/items.json`**: 모든 아이템에 고유 `id` 필드를 추가했습니다. (예: `"id": "rusty_dagger"`)
- **`assets/data/enemies.json`**: 적 정보에 `equipment` 필드(아이템 ID 리스트)를 추가했습니다. (예: `Goblin``rusty_dagger` 장착)
### 2. 게임 로직 업데이트
- **`lib/game/model/item.dart`**: `Item` 클래스에 `id` 필드를 추가했습니다.
- **`lib/game/data/item_table.dart`**: ID로 아이템을 조회하는 `get(String id)` 메서드를 구현했습니다.
- **`lib/game/data/enemy_table.dart`**: 적 생성(`createCharacter`) 시 `equipment` 필드에 명시된 아이템을 자동으로 인벤토리에 추가하고 장착하도록 수정했습니다.
- **`lib/providers/battle_provider.dart`**: 초기 플레이어 지급 아이템 생성 시 `id`를 포함하도록 수정했습니다.
### 3. 버그 수정
- **`lib/screens/battle_screen.dart`**: `ScrollController`가 연결되지 않아 발생하던 에러를 수정했습니다. (불필요한 컨트롤러 제거)
## 결과
이제 `enemies.json` 설정만으로 적에게 다양한 장비를 입혀 스탯과 특수 효과(출혈, 스턴 등)를 부여할 수 있습니다.

View File

@ -0,0 +1,35 @@
# 플레이어 데이터 중앙화 (Centralize Player Data)
## 목표
캐릭터 선택 화면과 전투 시스템(`BattleProvider`)에서 사용하는 플레이어 스탯이 하드코딩되어 불일치하는 문제를 해결하기 위해, 플레이어 데이터를 JSON 파일로 중앙화하여 관리합니다.
## 주요 변경 사항
### 1. 데이터 구조 추가
- **`assets/data/players.json`**: 플레이어 템플릿 데이터를 정의했습니다.
```json
[
{
"id": "warrior",
"name": "Warrior",
"description": "A balanced fighter...",
"baseHp": 50,
"baseAtk": 5,
"baseDefense": 5,
...
}
]
```
- **`lib/game/data/player_table.dart`**: `players.json`을 로드하고 파싱하는 `PlayerTable` 클래스를 구현했습니다.
### 2. 게임 로직 업데이트
- **`lib/main.dart`**: 앱 시작 시 `PlayerTable.load()`를 호출하여 데이터를 메모리에 적재합니다.
- **`lib/screens/character_selection_screen.dart`**: 하드코딩된 텍스트 대신 `PlayerTable.get("warrior")`를 사용하여 UI를 렌더링합니다.
- **`lib/providers/battle_provider.dart`**: 전투 초기화(`initializeBattle`) 시 `PlayerTable`에서 캐릭터 정보를 가져와 생성합니다.
## 결과
이제 `players.json` 파일만 수정하면 게임 내 모든 곳(선택 화면, 전투 시작 스탯 등)에 일관되게 반영됩니다.

View File

@ -13,6 +13,7 @@ void main() {
setUp(() { setUp(() {
player = Character(name: "TestPlayer", maxHp: 100, armor: 0, atk: 10); player = Character(name: "TestPlayer", maxHp: 100, armor: 0, atk: 10);
armorHp50 = Item( armorHp50 = Item(
id: "armor_hp_50",
name: "Armor +50", name: "Armor +50",
description: "HP +50", description: "HP +50",
atkBonus: 0, atkBonus: 0,
@ -21,6 +22,7 @@ void main() {
price: 100, price: 100,
); );
armorHp100 = Item( armorHp100 = Item(
id: "armor_hp_100",
name: "Armor +100", name: "Armor +100",
description: "HP +100", description: "HP +100",
atkBonus: 0, atkBonus: 0,
@ -29,6 +31,7 @@ void main() {
price: 200, price: 200,
); );
armorHp20 = Item( armorHp20 = Item(
id: "armor_hp_20",
name: "Armor +20", name: "Armor +20",
description: "HP +20", description: "HP +20",
atkBonus: 0, atkBonus: 0,