Files
asciinevrdie/lib/src/features/game/widgets/ascii_animation_card.dart

768 lines
24 KiB
Dart

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<AsciiAnimationCard> createState() => _AsciiAnimationCardState();
}
class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
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<String>? _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<String>? _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<AsciiLayer> _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,
),
),
],
),
);
}
}