import 'dart:async'; import 'package:flutter/material.dart'; import 'package:asciineverdie/src/core/animation/ascii_animation_data.dart'; import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.dart'; import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart'; import 'package:asciineverdie/src/core/animation/background_layer.dart'; import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_widget.dart'; import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart'; import 'package:asciineverdie/src/core/animation/canvas/canvas_battle_composer.dart'; import 'package:asciineverdie/src/core/animation/canvas/canvas_special_composer.dart'; import 'package:asciineverdie/src/core/animation/canvas/canvas_town_composer.dart'; import 'package:asciineverdie/src/core/animation/canvas/canvas_walking_composer.dart'; import 'package:asciineverdie/src/core/animation/character_frames.dart'; import 'package:asciineverdie/src/core/animation/monster_size.dart'; import 'package:asciineverdie/src/core/animation/weapon_category.dart'; import 'package:asciineverdie/src/core/constants/ascii_colors.dart'; import 'package:asciineverdie/src/core/model/combat_event.dart'; import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/item_stats.dart'; import 'package:asciineverdie/src/core/model/monster_grade.dart'; /// 애니메이션 모드 enum AnimationMode { battle, // 전투 walking, // 걷기 town, // 마을/상점 special, // 특수 이벤트 } /// ASCII 애니메이션 카드 위젯 (전체 Canvas 기반) /// /// TaskType에 따라 다른 애니메이션을 표시. /// 전투 시 몬스터 이름에 따라 다른 애니메이션 선택. /// 특수 이벤트(레벨업, 퀘스트 완료) 시 오버라이드 애니메이션 재생. /// 자체 타이머로 프레임 전환 (게임 틱과 독립). class AsciiAnimationCard extends StatefulWidget { const AsciiAnimationCard({ super.key, required this.taskType, this.monsterBaseName, this.colorTheme = AsciiColorTheme.green, this.specialAnimation, this.weaponName, this.shieldName, this.characterLevel, this.monsterLevel, this.monsterGrade, this.isPaused = false, this.isInCombat = true, this.monsterDied = false, this.latestCombatEvent, this.raceId, this.weaponRarity, this.opponentRaceId, this.opponentHasShield = false, }); final TaskType taskType; /// 일시정지 상태 (true면 애니메이션 정지) final bool isPaused; /// 전투 활성 상태 (false면 kill 태스크여도 walking 애니메이션) final bool isInCombat; /// 몬스터 사망 여부 (true면 분해 애니메이션 재생) final bool monsterDied; /// 전투 중인 몬스터 기본 이름 (kill 타입일 때만 사용) final String? monsterBaseName; final AsciiColorTheme colorTheme; /// 특수 애니메이션 오버라이드 (레벨업, 퀘스트 완료 등) /// 설정되면 일반 애니메이션 대신 표시 final AsciiAnimationType? specialAnimation; /// 현재 장착 무기 이름 (공격 스타일 결정용) final String? weaponName; /// 현재 장착 방패 이름 (방패 표시용) final String? shieldName; /// 캐릭터 레벨 final int? characterLevel; /// 몬스터 레벨 (몬스터 크기 결정용) final int? monsterLevel; /// 몬스터 등급 (Normal/Elite/Boss) - 색상/접두사 표시용 final MonsterGrade? monsterGrade; /// 최근 전투 이벤트 (애니메이션 동기화용) final CombatEvent? latestCombatEvent; /// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션) final String? raceId; /// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상) final ItemRarity? weaponRarity; /// 상대 종족 ID (PvP 모드: 설정 시 몬스터 대신 캐릭터 표시) final String? opponentRaceId; /// 상대 방패 장착 여부 (PvP 모드) final bool opponentHasShield; @override State createState() => _AsciiAnimationCardState(); } class _AsciiAnimationCardState extends State { Timer? _timer; int _currentFrame = 0; AsciiAnimationType? _currentSpecialAnimation; // 애니메이션 모드 AnimationMode _animationMode = AnimationMode.walking; // Composer 인스턴스들 CanvasBattleComposer? _battleComposer; CanvasWalkingComposer? _walkingComposer; CanvasTownComposer? _townComposer; final _specialComposer = const CanvasSpecialComposer(); // 전투 애니메이션 상태 BattlePhase _battlePhase = BattlePhase.idle; int _battleSubFrame = 0; // 글로벌 틱 (배경 스크롤용) int _globalTick = 0; // 특수 애니메이션 틱 카운터 (프레임 간격 계산용) int _specialTick = 0; // 환경 타입 EnvironmentType _environment = EnvironmentType.forest; // 전투 페이즈 시퀀스 (반복) static const _battlePhaseSequence = [ (BattlePhase.idle, 4), // 4 프레임 대기 (BattlePhase.prepare, 2), // 2 프레임 준비 (BattlePhase.attack, 3), // 3 프레임 공격 (BattlePhase.hit, 2), // 2 프레임 히트 (BattlePhase.recover, 2), // 2 프레임 복귀 ]; int _phaseIndex = 0; int _phaseFrameCount = 0; // 전투 이벤트 동기화용 (Phase 5) int? _lastEventTimestamp; bool _showCriticalEffect = false; bool _showBlockEffect = false; bool _showParryEffect = false; bool _showSkillEffect = false; // 추가 전투 이펙트 (Phase 11) bool _showEvadeEffect = false; bool _showMissEffect = false; bool _showDebuffEffect = false; bool _showDotEffect = false; // 공격 속도 기반 동적 페이즈 프레임 수 (Phase 6) int _eventDrivenPhaseFrames = 0; bool _isEventDrivenPhase = false; // 공격자 타입 (Phase 7: 공격자별 위치 분리) AttackerType _currentAttacker = AttackerType.none; // 특수 애니메이션 프레임 수는 ascii_animation_type.dart의 // specialAnimationFrameCounts 상수 사용 // 몬스터 사망 분해 애니메이션 상태 bool _showDeathAnimation = false; List? _deathAnimationMonsterLines; String? _lastMonsterBaseName; @override void initState() { super.initState(); _updateAnimation(); } @override void didUpdateWidget(AsciiAnimationCard oldWidget) { super.didUpdateWidget(oldWidget); // 일시정지 상태 변경 처리 if (oldWidget.isPaused != widget.isPaused) { if (widget.isPaused) { _timer?.cancel(); _timer = null; } else { // 재개 시: specialAnimation 동기화 (isPaused와 동시에 변경될 수 있음) // 예: 부활 시 isPaused가 true→false로 바뀌면서 동시에 // specialAnimation이 null→resurrection으로 변경됨 if (widget.specialAnimation != _currentSpecialAnimation) { _currentSpecialAnimation = widget.specialAnimation; _updateAnimation(); // _updateAnimation이 타이머 재시작도 처리함 return; } // 일반 재개: 애니메이션 재시작 (현재 프레임 유지) _restartTimer(); } return; } // 특수 애니메이션이 변경되었으면 업데이트 if (oldWidget.specialAnimation != widget.specialAnimation) { _currentSpecialAnimation = widget.specialAnimation; _updateAnimation(); return; } // 특수 애니메이션이 활성화되어 있으면 일반 업데이트 무시 if (_currentSpecialAnimation != null) { return; } // 몬스터 사망 애니메이션 트리거 if (!oldWidget.monsterDied && widget.monsterDied && !_showDeathAnimation) { // 현재 몬스터 프레임 캡처 (분해 애니메이션용) _deathAnimationMonsterLines = _captureMonsterFrame(); if (_deathAnimationMonsterLines != null) { setState(() { _showDeathAnimation = true; }); // 분해 애니메이션은 오버레이로 표시되므로 // 백그라운드 상태 업데이트는 계속 진행 (20배속 대응) } } // 전투 이벤트 동기화 (Phase 5) if (widget.latestCombatEvent != null && widget.latestCombatEvent!.timestamp != _lastEventTimestamp) { _handleCombatEvent(widget.latestCombatEvent!); } // 몬스터 이름 저장 (사망 시 프레임 캡처용) if (widget.monsterBaseName != null) { _lastMonsterBaseName = widget.monsterBaseName; } if (oldWidget.taskType != widget.taskType || oldWidget.monsterBaseName != widget.monsterBaseName || oldWidget.weaponName != widget.weaponName || oldWidget.shieldName != widget.shieldName || oldWidget.monsterLevel != widget.monsterLevel || oldWidget.raceId != widget.raceId || oldWidget.weaponRarity != widget.weaponRarity || oldWidget.opponentRaceId != widget.opponentRaceId || oldWidget.opponentHasShield != widget.opponentHasShield || oldWidget.isInCombat != widget.isInCombat || oldWidget.monsterDied != widget.monsterDied) { _updateAnimation(); } } /// 전투 이벤트에 따라 애니메이션 페이즈 강제 전환 (Phase 5, 11) void _handleCombatEvent(CombatEvent event) { _lastEventTimestamp = event.timestamp; // 전투 모드가 아니면 무시 if (_animationMode != AnimationMode.battle) return; // 이벤트 타입에 따라 페이즈 및 효과 결정 // (targetPhase, isCritical, isBlock, isParry, isSkill, isEvade, isMiss, isDebuff, isDot) final ( targetPhase, isCritical, isBlock, isParry, isSkill, isEvade, isMiss, isDebuff, isDot, ) = switch (event.type) { // 플레이어 공격 → prepare 페이즈부터 시작 (준비 동작 표시) CombatEventType.playerAttack => ( BattlePhase.prepare, event.isCritical, false, false, false, false, false, false, false, ), // 스킬 사용 → prepare 페이즈부터 시작 + 스킬 이펙트 CombatEventType.playerSkill => ( BattlePhase.prepare, event.isCritical, false, false, true, false, false, false, false, ), // 몬스터 공격 → prepare 페이즈부터 시작 CombatEventType.monsterAttack => ( BattlePhase.prepare, false, false, false, false, false, false, false, false, ), // 블록 → hit 페이즈 + 블록 이펙트 + 텍스트 CombatEventType.playerBlock => ( BattlePhase.hit, false, true, false, false, false, false, false, false, ), // 패리 → hit 페이즈 + 패리 이펙트 + 텍스트 CombatEventType.playerParry => ( BattlePhase.hit, false, false, true, false, false, false, false, false, ), // 플레이어 회피 → recover 페이즈 + 회피 텍스트 CombatEventType.playerEvade => ( BattlePhase.recover, false, false, false, false, true, false, false, false, ), // 몬스터 회피 → idle 페이즈 + 미스 텍스트 CombatEventType.monsterEvade => ( BattlePhase.idle, false, false, false, false, false, true, false, false, ), // 회복/버프 → idle 페이즈 유지 CombatEventType.playerHeal => ( BattlePhase.idle, false, false, false, false, false, false, false, false, ), CombatEventType.playerBuff => ( BattlePhase.idle, false, false, false, false, false, false, false, false, ), // 디버프 적용 → idle 페이즈 + 디버프 텍스트 CombatEventType.playerDebuff => ( BattlePhase.idle, false, false, false, false, false, false, true, false, ), // DOT 틱 → attack 페이즈 + DOT 텍스트 CombatEventType.dotTick => ( BattlePhase.attack, false, false, false, false, false, false, false, true, ), // 물약 사용 → idle 페이즈 유지 CombatEventType.playerPotion => ( BattlePhase.idle, false, false, false, false, false, false, false, false, ), // 물약 드랍 → idle 페이즈 유지 CombatEventType.potionDrop => ( BattlePhase.idle, false, false, false, false, false, false, false, false, ), }; setState(() { _battlePhase = targetPhase; _battleSubFrame = 0; _phaseFrameCount = 0; _showCriticalEffect = isCritical; _showBlockEffect = isBlock; _showParryEffect = isParry; _showSkillEffect = isSkill; _showEvadeEffect = isEvade; _showMissEffect = isMiss; _showDebuffEffect = isDebuff; _showDotEffect = isDot; // 페이즈 인덱스 동기화 _phaseIndex = _battlePhaseSequence.indexWhere((p) => p.$1 == targetPhase); if (_phaseIndex < 0) _phaseIndex = 0; // 공격 속도에 따른 동적 페이즈 프레임 수 계산 (Phase 6) // 200ms tick 기준으로 프레임 수 계산 (최소 2, 최대 10) if (event.attackDelayMs != null && event.attackDelayMs! > 0) { _eventDrivenPhaseFrames = (event.attackDelayMs! ~/ 200).clamp(2, 10); _isEventDrivenPhase = true; } else { _isEventDrivenPhase = false; } // 공격자 타입 결정 (Phase 7: 공격자별 위치 분리) _currentAttacker = switch (event.type) { CombatEventType.playerAttack || CombatEventType.playerSkill => AttackerType.player, CombatEventType.monsterAttack => AttackerType.monster, _ => AttackerType.none, }; }); } /// 현재 상태를 유지하면서 타이머만 재시작 void _restartTimer() { _timer?.cancel(); _startTimer(); } /// 타이머 시작 void _startTimer() { const tickInterval = Duration(milliseconds: 200); _timer = Timer.periodic(tickInterval, (_) { if (!mounted) return; setState(() { _globalTick++; if (_animationMode == AnimationMode.special) { _specialTick++; // 특수 애니메이션 프레임 간격 계산 (200ms tick 기준) // 예: resurrection 600ms → 600/200 = 3 tick마다 1 프레임 final frameInterval = (specialAnimationFrameIntervals[_currentSpecialAnimation] ?? 200) ~/ 200; if (_specialTick >= frameInterval) { _specialTick = 0; _currentFrame++; final maxFrames = specialAnimationFrameCounts[_currentSpecialAnimation] ?? 5; // 마지막 프레임에 도달하면 특수 애니메이션 종료 if (_currentFrame >= maxFrames) { _currentSpecialAnimation = null; _updateAnimation(); } } } else if (_animationMode == AnimationMode.battle) { _advanceBattleFrame(); } // walking, town은 globalTick만 증가하면 됨 }); }); } void _updateAnimation() { _timer?.cancel(); // 특수 애니메이션이 있으면 우선 적용 if (_currentSpecialAnimation != null) { _animationMode = AnimationMode.special; _currentFrame = 0; _specialTick = 0; // 특수 애니메이션은 게임 일시정지와 무관하게 항상 재생 _startTimer(); return; } // 일반 애니메이션 처리 final animationType = taskTypeToAnimation(widget.taskType); switch (animationType) { case AsciiAnimationType.battle: // 전투 비활성 상태면 walking 모드로 전환 (몬스터 처치 후 이동 중) if (!widget.isInCombat) { _animationMode = AnimationMode.walking; _walkingComposer = CanvasWalkingComposer(raceId: widget.raceId); } else { _animationMode = AnimationMode.battle; _setupBattleComposer(); _battlePhase = BattlePhase.idle; _battleSubFrame = 0; _phaseIndex = 0; _phaseFrameCount = 0; } case AsciiAnimationType.town: _animationMode = AnimationMode.town; _townComposer = CanvasTownComposer(raceId: widget.raceId); case AsciiAnimationType.walking: _animationMode = AnimationMode.walking; _walkingComposer = CanvasWalkingComposer(raceId: widget.raceId); default: _animationMode = AnimationMode.walking; _walkingComposer = CanvasWalkingComposer(raceId: widget.raceId); } // 일시정지 상태면 타이머 시작하지 않음 if (widget.isPaused) return; _startTimer(); } void _setupBattleComposer() { final weaponCategory = getWeaponCategory(widget.weaponName); final hasShield = widget.shieldName != null && widget.shieldName!.isNotEmpty; final monsterCategory = getMonsterCategory(widget.monsterBaseName); final monsterSize = getMonsterSize(widget.monsterLevel); _battleComposer = CanvasBattleComposer( weaponCategory: weaponCategory, hasShield: hasShield, monsterCategory: monsterCategory, monsterSize: monsterSize, raceId: widget.raceId, weaponRarity: widget.weaponRarity, opponentRaceId: widget.opponentRaceId, opponentHasShield: widget.opponentHasShield, ); // 환경 타입 추론 _environment = inferEnvironment( widget.taskType.name, widget.monsterBaseName, ); } /// 현재 몬스터 프레임을 텍스트 라인으로 캡처 (분해 애니메이션용) List? _captureMonsterFrame() { final monsterName = _lastMonsterBaseName ?? widget.monsterBaseName; if (monsterName == null) return null; final monsterCategory = getMonsterCategory(monsterName); final monsterSize = getMonsterSize(widget.monsterLevel); // 몬스터 Idle 프레임 가져오기 final frames = getMonsterIdleFrames(monsterCategory, monsterSize); if (frames.isEmpty) return null; return frames.first; } /// 사망 애니메이션 완료 콜백 void _onDeathAnimationComplete() { setState(() { _showDeathAnimation = false; _deathAnimationMonsterLines = null; }); // Walking 모드로 전환 _updateAnimation(); } void _advanceBattleFrame() { _phaseFrameCount++; final currentPhase = _battlePhaseSequence[_phaseIndex]; // 현재 페이즈의 프레임 수 결정 (Phase 6) // 이벤트 기반 페이즈일 경우 공격 속도에 따른 동적 프레임 수 사용 final targetFrames = _isEventDrivenPhase ? _eventDrivenPhaseFrames : currentPhase.$2; // 현재 페이즈의 프레임 수를 초과하면 다음 페이즈로 if (_phaseFrameCount >= targetFrames) { _phaseIndex = (_phaseIndex + 1) % _battlePhaseSequence.length; _phaseFrameCount = 0; _battleSubFrame = 0; // 이펙트 리셋 (페이즈 전환 시) _showCriticalEffect = false; _showBlockEffect = false; _showParryEffect = false; _showSkillEffect = false; _showEvadeEffect = false; _showMissEffect = false; _showDebuffEffect = false; _showDotEffect = false; // 공격자 타입 및 이벤트 기반 페이즈 리셋 (idle 페이즈 진입 시에만) // 공격 사이클(prepare→attack→hit→recover) 동안 유지 (Bug fix) if (_battlePhaseSequence[_phaseIndex].$1 == BattlePhase.idle) { _currentAttacker = AttackerType.none; _isEventDrivenPhase = false; } } else { _battleSubFrame++; } _battlePhase = _battlePhaseSequence[_phaseIndex].$1; } @override void dispose() { _timer?.cancel(); super.dispose(); } /// 현재 애니메이션 레이어 생성 List _composeLayers() { return switch (_animationMode) { AnimationMode.battle => _battleComposer?.composeLayers( _battlePhase, _battleSubFrame, widget.monsterBaseName, _environment, _globalTick, attacker: _currentAttacker, isCritical: _showCriticalEffect, isEvade: _showEvadeEffect, isMiss: _showMissEffect, isDebuff: _showDebuffEffect, isDot: _showDotEffect, isBlock: _showBlockEffect, isParry: _showParryEffect, hideMonster: _showDeathAnimation, ) ?? [AsciiLayer.empty()], AnimationMode.walking => _walkingComposer?.composeLayers(_globalTick) ?? [AsciiLayer.empty()], AnimationMode.town => _townComposer?.composeLayers(_globalTick) ?? [AsciiLayer.empty()], AnimationMode.special => _specialComposer.composeLayers( _currentSpecialAnimation ?? AsciiAnimationType.levelUp, _currentFrame, _globalTick, ), }; } @override Widget build(BuildContext context) { // 테마 인식 배경색 (다크: 검정, 라이트: 양피지) final bgColor = AsciiColors.backgroundOf(context); final positiveColor = AsciiColors.positiveOf(context); // 테두리 효과 결정 (전투 이벤트 또는 특수 애니메이션) final isSpecial = _currentSpecialAnimation != null; Border? borderEffect; if (_showCriticalEffect) { // 크리티컬 히트: 노란색 테두리 borderEffect = Border.all( color: Colors.yellow.withValues(alpha: 0.8), width: 2, ); } else if (_showBlockEffect) { // 블록 (방패 방어): 파란색 테두리 borderEffect = Border.all( color: Colors.blue.withValues(alpha: 0.8), width: 2, ); } else if (_showParryEffect) { // 패리 (무기 쳐내기): 주황색 테두리 borderEffect = Border.all( color: Colors.orange.withValues(alpha: 0.8), width: 2, ); } else if (_showSkillEffect) { // 스킬 사용: 마젠타 테두리 borderEffect = Border.all( color: Colors.purple.withValues(alpha: 0.8), width: 2, ); } else if (isSpecial) { // 특수 애니메이션: 포지티브 색상 테두리 borderEffect = Border.all(color: positiveColor.withValues(alpha: 0.5)); } return Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(4), border: borderEffect, ), child: Stack( children: [ // 기본 애니메이션 AsciiCanvasWidget(layers: _composeLayers()), // 몬스터 사망 분해 애니메이션 오버레이 // 몬스터 위치: 캔버스 60열 중 30~48열 (중앙값 41열) // Alignment x = (41/60) * 2 - 1 = 0.37 if (_showDeathAnimation && _deathAnimationMonsterLines != null) Align( alignment: const Alignment(0.37, 0.0), child: AsciiDisintegrateWidget( characterLines: _deathAnimationMonsterLines!, duration: const Duration(milliseconds: 800), textColor: widget.monsterGrade?.displayColor, onComplete: _onDeathAnimationComplete, ), ), ], ), ); } }