Compare commits

...

3 Commits

Author SHA1 Message Date
JiWoong Sul
f9a4ae105a test: 새 캐릭터 화면 테스트 개선 2026-01-14 02:26:27 +09:00
JiWoong Sul
81eb2f8463 feat(ui): 아레나 결과 패널 및 애니메이션 카드 개선 2026-01-14 02:26:22 +09:00
JiWoong Sul
eba0521ffe refactor(core): 애니메이션 컴포저 및 저장 데이터 개선
- CanvasWalkingComposer 정리
- SaveData 모델 확장
2026-01-14 02:26:18 +09:00
5 changed files with 228 additions and 60 deletions

View File

@@ -89,19 +89,11 @@ class CanvasWalkingComposer {
} }
/// idle 프레임 기반 걷기 애니메이션 생성 /// idle 프레임 기반 걷기 애니메이션 생성
/// 머리와 몸통은 유지, 다리만 걷는 동작으로 변경 /// 종족별 다리 모양을 유지 (idle 프레임이 4개라 자연스럽게 변화)
List<String> _animateWalking(List<String> idleLines, int frameIndex) { List<String> _animateWalking(List<String> idleLines, int frameIndex) {
if (idleLines.length < 3) return idleLines; // idle 프레임을 그대로 사용 (종족별 다리 모양 유지)
// frameIndex에 따라 idle[0~3] 중 하나가 선택되어 자연스럽게 애니메이션됨
// 머리(0)와 몸통(1)은 그대로 유지 return idleLines;
final head = idleLines[0];
final body = idleLines[1];
// 다리 애니메이션 (4프레임) - 걷기 동작
const legFrames = [' /| ', ' |\\ ', ' /| ', ' |\\ '];
final legs = legFrames[frameIndex % legFrames.length];
return [head, body, legs];
} }
/// 문자열 스프라이트를 AsciiCell 2D 배열로 변환 /// 문자열 스프라이트를 AsciiCell 2D 배열로 변환

View File

@@ -1,9 +1,15 @@
import 'dart:collection'; import 'dart:collection';
import 'package:asciineverdie/src/core/model/equipment_item.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart'; import 'package:asciineverdie/src/core/util/deterministic_random.dart';
import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/game_state.dart';
const int kSaveVersion = 2; /// 세이브 파일 버전
/// - v2: 장비 이름만 저장 (레거시)
/// - v3: 장비 전체 정보 저장 (level, rarity, stats 포함)
const int kSaveVersion = 3;
class GameSave { class GameSave {
GameSave({ GameSave({
@@ -79,17 +85,7 @@ class GameSave {
.toList(), .toList(),
}, },
'equipment': { 'equipment': {
'weapon': equipment.weapon, 'items': equipment.items.map((e) => e.toJson()).toList(),
'shield': equipment.shield,
'helm': equipment.helm,
'hauberk': equipment.hauberk,
'brassairts': equipment.brassairts,
'vambraces': equipment.vambraces,
'gauntlets': equipment.gauntlets,
'gambeson': equipment.gambeson,
'cuisses': equipment.cuisses,
'greaves': equipment.greaves,
'sollerets': equipment.sollerets,
'bestIndex': equipment.bestIndex, 'bestIndex': equipment.bestIndex,
}, },
'skills': skillBook.skills 'skills': skillBook.skills
@@ -106,6 +102,8 @@ class GameSave {
'type': progress.currentTask.type.name, 'type': progress.currentTask.type.name,
'monsterBaseName': progress.currentTask.monsterBaseName, 'monsterBaseName': progress.currentTask.monsterBaseName,
'monsterPart': progress.currentTask.monsterPart, 'monsterPart': progress.currentTask.monsterPart,
'monsterLevel': progress.currentTask.monsterLevel,
'monsterGrade': progress.currentTask.monsterGrade?.name,
}, },
'plotStages': progress.plotStageCount, 'plotStages': progress.plotStageCount,
'questCount': progress.questCount, 'questCount': progress.questCount,
@@ -183,20 +181,7 @@ class GameSave {
) )
.toList(), .toList(),
), ),
equipment: Equipment.fromStrings( equipment: _equipmentFromJson(equipmentJson, json['version'] as int? ?? 2),
weapon: equipmentJson['weapon'] as String? ?? 'Keyboard',
shield: equipmentJson['shield'] as String? ?? '',
helm: equipmentJson['helm'] as String? ?? '',
hauberk: equipmentJson['hauberk'] as String? ?? '',
brassairts: equipmentJson['brassairts'] as String? ?? '',
vambraces: equipmentJson['vambraces'] as String? ?? '',
gauntlets: equipmentJson['gauntlets'] as String? ?? '',
gambeson: equipmentJson['gambeson'] as String? ?? '',
cuisses: equipmentJson['cuisses'] as String? ?? '',
greaves: equipmentJson['greaves'] as String? ?? '',
sollerets: equipmentJson['sollerets'] as String? ?? '',
bestIndex: equipmentJson['bestIndex'] as int? ?? 0,
),
skillBook: SkillBook( skillBook: SkillBook(
skills: skillsJson skills: skillsJson
.map( .map(
@@ -289,11 +274,23 @@ TaskInfo _taskInfoFromJson(Map<String, dynamic> json) {
(t) => t.name == typeName, (t) => t.name == typeName,
orElse: () => TaskType.neutral, orElse: () => TaskType.neutral,
); );
// monsterGrade 파싱
final gradeName = json['monsterGrade'] as String?;
final monsterGrade = gradeName != null
? MonsterGrade.values.firstWhere(
(g) => g.name == gradeName,
orElse: () => MonsterGrade.normal,
)
: null;
return TaskInfo( return TaskInfo(
caption: json['caption'] as String? ?? '', caption: json['caption'] as String? ?? '',
type: type, type: type,
monsterBaseName: json['monsterBaseName'] as String?, monsterBaseName: json['monsterBaseName'] as String?,
monsterPart: json['monsterPart'] as String?, monsterPart: json['monsterPart'] as String?,
monsterLevel: json['monsterLevel'] as int?,
monsterGrade: monsterGrade,
); );
} }
@@ -315,3 +312,46 @@ QuestMonsterInfo? _questMonsterFromJson(Map<String, dynamic>? json) {
monsterIndex: json['index'] as int? ?? -1, monsterIndex: json['index'] as int? ?? -1,
); );
} }
/// 장비 데이터 역직렬화 (버전별 분기 처리)
///
/// - v2 이하: 레거시 문자열 기반 (이름만 저장)
/// - v3 이상: 전체 EquipmentItem 정보 저장
Equipment _equipmentFromJson(Map<String, dynamic> json, int version) {
// v3 이상: 새로운 형식 (items 배열)
if (version >= 3 && json['items'] != null) {
final itemsList = json['items'] as List<dynamic>;
final items = <EquipmentItem>[];
for (var i = 0; i < Equipment.slotCount; i++) {
if (i < itemsList.length) {
final itemJson = itemsList[i] as Map<String, dynamic>;
items.add(EquipmentItem.fromJson(itemJson));
} else {
// 누락된 슬롯은 빈 아이템으로 채움
items.add(EquipmentItem.empty(EquipmentSlot.values[i]));
}
}
return Equipment(
items: items,
bestIndex: json['bestIndex'] as int? ?? 0,
);
}
// v2 이하: 레거시 형식 (문자열 기반)
return Equipment.fromStrings(
weapon: json['weapon'] as String? ?? 'Keyboard',
shield: json['shield'] as String? ?? '',
helm: json['helm'] as String? ?? '',
hauberk: json['hauberk'] as String? ?? '',
brassairts: json['brassairts'] as String? ?? '',
vambraces: json['vambraces'] as String? ?? '',
gauntlets: json['gauntlets'] as String? ?? '',
gambeson: json['gambeson'] as String? ?? '',
cuisses: json['cuisses'] as String? ?? '',
greaves: json['greaves'] as String? ?? '',
sollerets: json['sollerets'] as String? ?? '',
bestIndex: json['bestIndex'] as int? ?? 0,
);
}

View File

@@ -10,6 +10,7 @@ import 'package:asciineverdie/src/core/engine/item_service.dart';
import 'package:asciineverdie/src/core/model/arena_match.dart'; import 'package:asciineverdie/src/core/model/arena_match.dart';
import 'package:asciineverdie/src/core/model/equipment_item.dart'; import 'package:asciineverdie/src/core/model/equipment_item.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart'; import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart'; import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart'; import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart';
@@ -103,6 +104,9 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
final fileName = 'arena_${challenger}_vs_${opponent}_$timestamp.json'; final fileName = 'arena_${challenger}_vs_${opponent}_$timestamp.json';
final file = File('${directory.path}/$fileName'); final file = File('${directory.path}/$fileName');
// 전투 통계 계산
final stats = _calculateBattleStats();
final jsonData = { final jsonData = {
'match': { 'match': {
'challenger': challenger, 'challenger': challenger,
@@ -111,6 +115,11 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
'turnCount': widget.turnCount, 'turnCount': widget.turnCount,
'timestamp': DateTime.now().toIso8601String(), 'timestamp': DateTime.now().toIso8601String(),
}, },
'characters': {
'challenger': _characterToJson(widget.result.match.challenger),
'opponent': _characterToJson(widget.result.match.opponent),
},
'stats': stats,
'battleLog': widget.battleLog!.map((e) => e.toJson()).toList(), 'battleLog': widget.battleLog!.map((e) => e.toJson()).toList(),
}; };
@@ -146,6 +155,126 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
} }
} }
/// 캐릭터 정보를 JSON으로 변환
Map<String, dynamic> _characterToJson(HallOfFameEntry entry) {
return {
'name': entry.characterName,
'level': entry.level,
'race': entry.race,
'class': entry.klass,
'combatStats': entry.finalStats?.toJson(),
'equipment': entry.finalEquipment
?.map((EquipmentItem e) => {
'slot': e.slot.name,
'name': e.name,
'level': e.level,
'rarity': e.rarity.name,
'stats': e.stats.toJson(),
})
.toList(),
'skills': entry.finalSkills,
};
}
/// 배틀 로그에서 전투 통계 계산
Map<String, dynamic> _calculateBattleStats() {
if (widget.battleLog == null || widget.battleLog!.isEmpty) {
return {};
}
int challengerTotalDamage = 0;
int opponentTotalDamage = 0;
int challengerTotalHeal = 0;
int opponentTotalHeal = 0;
int challengerCriticals = 0;
int opponentCriticals = 0;
int challengerBlocks = 0;
int opponentBlocks = 0;
int challengerEvades = 0;
int opponentEvades = 0;
int challengerSkillsUsed = 0;
int opponentSkillsUsed = 0;
final challenger = widget.result.match.challenger.characterName;
for (final entry in widget.battleLog!) {
final msg = entry.message;
final isChallenger = msg.startsWith(challenger);
switch (entry.type) {
case CombatLogType.damage:
final dmg = _extractNumber(msg);
if (isChallenger) {
challengerTotalDamage += dmg;
}
case CombatLogType.monsterAttack:
final dmg = _extractNumber(msg);
opponentTotalDamage += dmg;
case CombatLogType.critical:
final dmg = _extractNumber(msg);
if (isChallenger) {
challengerTotalDamage += dmg;
challengerCriticals++;
} else {
opponentTotalDamage += dmg;
opponentCriticals++;
}
case CombatLogType.heal:
final heal = _extractNumber(msg);
if (isChallenger) {
challengerTotalHeal += heal;
} else {
opponentTotalHeal += heal;
}
case CombatLogType.block:
if (isChallenger) {
challengerBlocks++;
} else {
opponentBlocks++;
}
case CombatLogType.evade:
if (isChallenger) {
challengerEvades++;
} else {
opponentEvades++;
}
case CombatLogType.skill:
if (isChallenger) {
challengerSkillsUsed++;
} else {
opponentSkillsUsed++;
}
default:
break;
}
}
return {
'challenger': {
'totalDamage': challengerTotalDamage,
'totalHeal': challengerTotalHeal,
'criticals': challengerCriticals,
'blocks': challengerBlocks,
'evades': challengerEvades,
'skillsUsed': challengerSkillsUsed,
},
'opponent': {
'totalDamage': opponentTotalDamage,
'totalHeal': opponentTotalHeal,
'criticals': opponentCriticals,
'blocks': opponentBlocks,
'evades': opponentEvades,
'skillsUsed': opponentSkillsUsed,
},
};
}
/// 메시지에서 숫자 추출
int _extractNumber(String msg) {
final match = RegExp(r'(\d+)').firstMatch(msg);
return match != null ? int.tryParse(match.group(1)!) ?? 0 : 0;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isVictory = widget.result.isVictory; final isVictory = widget.result.isVictory;

View File

@@ -226,13 +226,11 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
setState(() { setState(() {
_showDeathAnimation = true; _showDeathAnimation = true;
}); });
return; // 사망 애니메이션 중에는 다른 업데이트 무시 // 분해 애니메이션은 오버레이로 표시되므로
// 백그라운드 상태 업데이트는 계속 진행 (20배속 대응)
} }
} }
// 사망 애니메이션 중에는 다른 업데이트 무시
if (_showDeathAnimation) return;
// 전투 이벤트 동기화 (Phase 5) // 전투 이벤트 동기화 (Phase 5)
if (widget.latestCombatEvent != null && if (widget.latestCombatEvent != null &&
widget.latestCombatEvent!.timestamp != _lastEventTimestamp) { widget.latestCombatEvent!.timestamp != _lastEventTimestamp) {
@@ -253,7 +251,8 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
oldWidget.weaponRarity != widget.weaponRarity || oldWidget.weaponRarity != widget.weaponRarity ||
oldWidget.opponentRaceId != widget.opponentRaceId || oldWidget.opponentRaceId != widget.opponentRaceId ||
oldWidget.opponentHasShield != widget.opponentHasShield || oldWidget.opponentHasShield != widget.opponentHasShield ||
oldWidget.isInCombat != widget.isInCombat) { oldWidget.isInCombat != widget.isInCombat ||
oldWidget.monsterDied != widget.monsterDied) {
_updateAnimation(); _updateAnimation();
} }
} }

View File

@@ -5,10 +5,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
/// 테스트용 MaterialApp 래퍼 (localization 포함) /// 테스트용 MaterialApp 래퍼 (localization 포함)
/// locale을 영어로 고정하여 테스트 텍스트와 일치시킴
Widget _buildTestApp(Widget child) { Widget _buildTestApp(Widget child) {
return MaterialApp( return MaterialApp(
localizationsDelegates: L10n.localizationsDelegates, localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales, supportedLocales: L10n.supportedLocales,
locale: const Locale('en'), // 영어 locale 고정
home: child, home: child,
); );
} }
@@ -20,23 +22,25 @@ void main() {
NewCharacterScreen(onCharacterCreated: (_, {bool testMode = false}) {}), NewCharacterScreen(onCharacterCreated: (_, {bool testMode = false}) {}),
), ),
); );
// Localization 로드 대기
await tester.pumpAndSettle();
// 화면 타이틀 확인 (l10n 적용됨) // 화면 타이틀 확인 (l10n 적용됨)
expect(find.text('ASCII NEVER DIE - New Character'), findsOneWidget); expect(find.text('ASCII NEVER DIE - NEW CHARACTER'), findsOneWidget);
// 종족 섹션 확인 // 종족 섹션 확인 (대문자 하드코딩)
expect(find.text('Race'), findsOneWidget); expect(find.text('RACE'), findsOneWidget);
// 직업 섹션 확인 // 직업 섹션 확인 (대문자 하드코딩)
expect(find.text('Class'), findsOneWidget); expect(find.text('CLASS'), findsOneWidget);
// 능력치 섹션 확인 // 능력치 섹션 확인 (대문자 하드코딩)
expect(find.text('Stats'), findsOneWidget); expect(find.text('STATS'), findsOneWidget);
expect(find.text('STR'), findsOneWidget); expect(find.text('STR'), findsOneWidget);
expect(find.text('CON'), findsOneWidget); expect(find.text('CON'), findsOneWidget);
// Sold! 버튼 확인 // Sold! 버튼 확인
expect(find.text('Sold!'), findsOneWidget); expect(find.text('SOLD!'), findsOneWidget);
}); });
testWidgets('Unroll button exists and can be tapped', (tester) async { testWidgets('Unroll button exists and can be tapped', (tester) async {
@@ -45,17 +49,18 @@ void main() {
NewCharacterScreen(onCharacterCreated: (_, {bool testMode = false}) {}), NewCharacterScreen(onCharacterCreated: (_, {bool testMode = false}) {}),
), ),
); );
await tester.pumpAndSettle();
// Unroll 버튼 확인 // Unroll 버튼 확인 (RetroTextButton이 대문자로 변환)
final unrollButton = find.text('Unroll'); final unrollButton = find.text('UNROLL');
expect(unrollButton, findsOneWidget); expect(unrollButton, findsOneWidget);
// Unroll 버튼 탭 // Unroll 버튼 탭
await tester.tap(unrollButton); await tester.tap(unrollButton);
await tester.pump(); await tester.pumpAndSettle();
// Total이 표시되는지 확인 // Total이 표시되는지 확인 (TOTAL은 대문자로 표시됨)
expect(find.textContaining('Total'), findsOneWidget); expect(find.textContaining('TOTAL'), findsOneWidget);
}); });
testWidgets('Sold button creates character with generated name', ( testWidgets('Sold button creates character with generated name', (
@@ -72,17 +77,18 @@ void main() {
), ),
), ),
); );
await tester.pumpAndSettle();
// Sold! 버튼이 보이도록 스크롤 // Sold! 버튼이 보이도록 스크롤
await tester.scrollUntilVisible( await tester.scrollUntilVisible(
find.text('Sold!'), find.text('SOLD!'),
500.0, 500.0,
scrollable: find.byType(Scrollable).first, scrollable: find.byType(Scrollable).first,
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Sold! 버튼 탭 // Sold! 버튼 탭
await tester.tap(find.text('Sold!')); await tester.tap(find.text('SOLD!'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// 콜백이 호출되었는지 확인 // 콜백이 호출되었는지 확인
@@ -99,6 +105,7 @@ void main() {
NewCharacterScreen(onCharacterCreated: (_, {bool testMode = false}) {}), NewCharacterScreen(onCharacterCreated: (_, {bool testMode = false}) {}),
), ),
); );
await tester.pumpAndSettle();
// 능력치 라벨들이 표시되는지 확인 // 능력치 라벨들이 표시되는지 확인
expect(find.text('STR'), findsOneWidget); expect(find.text('STR'), findsOneWidget);
@@ -108,8 +115,8 @@ void main() {
expect(find.text('WIS'), findsOneWidget); expect(find.text('WIS'), findsOneWidget);
expect(find.text('CHA'), findsOneWidget); expect(find.text('CHA'), findsOneWidget);
// Total 라벨 확인 // Total 라벨 확인 (TOTAL은 대문자로 표시됨)
expect(find.textContaining('Total'), findsOneWidget); expect(find.textContaining('TOTAL'), findsOneWidget);
}); });
testWidgets('Name text field exists', (tester) async { testWidgets('Name text field exists', (tester) async {
@@ -118,6 +125,7 @@ void main() {
NewCharacterScreen(onCharacterCreated: (_, {bool testMode = false}) {}), NewCharacterScreen(onCharacterCreated: (_, {bool testMode = false}) {}),
), ),
); );
await tester.pumpAndSettle();
// TextField 확인 (이름 입력 필드) // TextField 확인 (이름 입력 필드)
expect(find.byType(TextField), findsOneWidget); expect(find.byType(TextField), findsOneWidget);