Files
asciinevrdie/lib/src/features/game/widgets/ascii_animation_card.dart
JiWoong Sul 598c25e4c9 fix(animation): ASCII 애니메이션 높낮이/공백 문제 수정
- walkingAnimation, townAnimation 4줄 → 3줄 통일
- character_frames.dart 모든 프레임 폭 6자로 통일
- _compose() 이펙트 Y 위치 동적 계산 (하드코딩 제거)
- withShield() 3줄 캐릭터용으로 수정 (index 3 → index 1)
- BattleComposer 캔버스 시스템 및 배경 합성 추가
- 무기 카테고리별 이펙트, 몬스터 크기/색상 시스템 구현
2025-12-13 18:22:50 +09:00

326 lines
10 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_data.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:askiineverdie/src/core/animation/background_layer.dart';
import 'package:askiineverdie/src/core/animation/battle_composer.dart';
import 'package:askiineverdie/src/core/animation/character_frames.dart';
import 'package:askiineverdie/src/core/animation/monster_colors.dart';
import 'package:askiineverdie/src/core/animation/monster_size.dart';
import 'package:askiineverdie/src/core/animation/weapon_category.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
/// ASCII 애니메이션 카드 위젯
///
/// 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,
});
final TaskType taskType;
/// 전투 중인 몬스터 기본 이름 (kill 타입일 때만 사용)
final String? monsterBaseName;
final AsciiColorTheme colorTheme;
/// 특수 애니메이션 오버라이드 (레벨업, 퀘스트 완료 등)
/// 설정되면 일반 애니메이션 대신 표시
final AsciiAnimationType? specialAnimation;
/// 현재 장착 무기 이름 (공격 스타일 결정용)
final String? weaponName;
/// 현재 장착 방패 이름 (방패 표시용)
final String? shieldName;
/// 캐릭터 레벨
final int? characterLevel;
/// 몬스터 레벨 (몬스터 크기 결정용)
final int? monsterLevel;
@override
State<AsciiAnimationCard> createState() => _AsciiAnimationCardState();
}
class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
Timer? _timer;
int _currentFrame = 0;
late AsciiAnimationData _animationData;
AsciiAnimationType? _currentSpecialAnimation;
// 전투 애니메이션 상태
bool _isBattleMode = false;
BattlePhase _battlePhase = BattlePhase.idle;
int _battleSubFrame = 0;
BattleComposer? _battleComposer;
// 글로벌 틱 (배경 스크롤용)
int _globalTick = 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;
@override
void initState() {
super.initState();
_updateAnimation();
}
@override
void didUpdateWidget(AsciiAnimationCard oldWidget) {
super.didUpdateWidget(oldWidget);
// 특수 애니메이션이 변경되었으면 업데이트
if (oldWidget.specialAnimation != widget.specialAnimation) {
_currentSpecialAnimation = widget.specialAnimation;
_updateAnimation();
return;
}
// 특수 애니메이션이 활성화되어 있으면 일반 업데이트 무시
if (_currentSpecialAnimation != null) {
return;
}
if (oldWidget.taskType != widget.taskType ||
oldWidget.monsterBaseName != widget.monsterBaseName ||
oldWidget.weaponName != widget.weaponName ||
oldWidget.shieldName != widget.shieldName ||
oldWidget.monsterLevel != widget.monsterLevel) {
_updateAnimation();
}
}
void _updateAnimation() {
_timer?.cancel();
// 특수 애니메이션이 있으면 우선 적용
if (_currentSpecialAnimation != null) {
_isBattleMode = false;
_animationData = getAnimationData(_currentSpecialAnimation!);
_currentFrame = 0;
// 특수 애니메이션은 한 번 재생 후 종료
_timer = Timer.periodic(
Duration(milliseconds: _animationData.frameIntervalMs),
(_) {
if (mounted) {
setState(() {
_currentFrame++;
// 마지막 프레임에 도달하면 특수 애니메이션 종료
if (_currentFrame >= _animationData.frames.length) {
_currentSpecialAnimation = null;
_updateAnimation();
}
});
}
},
);
return;
}
// 일반 애니메이션 처리
final animationType = taskTypeToAnimation(widget.taskType);
// 전투 타입이면 새 BattleComposer 시스템 사용
if (animationType == AsciiAnimationType.battle) {
_isBattleMode = true;
_setupBattleComposer();
_battlePhase = BattlePhase.idle;
_battleSubFrame = 0;
_phaseIndex = 0;
_phaseFrameCount = 0;
_timer = Timer.periodic(
const Duration(milliseconds: 200),
(_) => _advanceBattleFrame(),
);
} else {
_isBattleMode = false;
_animationData = getAnimationData(animationType);
_currentFrame = 0;
_timer = Timer.periodic(
Duration(milliseconds: _animationData.frameIntervalMs),
(_) {
if (mounted) {
setState(() {
_currentFrame =
(_currentFrame + 1) % _animationData.frames.length;
});
}
},
);
}
}
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 = BattleComposer(
weaponCategory: weaponCategory,
hasShield: hasShield,
monsterCategory: monsterCategory,
monsterSize: monsterSize,
);
// 환경 타입 추론
_environment = inferEnvironment(
widget.taskType.name,
widget.monsterBaseName,
);
}
void _advanceBattleFrame() {
if (!mounted) return;
setState(() {
// 글로벌 틱 증가 (배경 스크롤용)
_globalTick++;
_phaseFrameCount++;
final currentPhase = _battlePhaseSequence[_phaseIndex];
// 현재 페이즈의 프레임 수를 초과하면 다음 페이즈로
if (_phaseFrameCount >= currentPhase.$2) {
_phaseIndex = (_phaseIndex + 1) % _battlePhaseSequence.length;
_phaseFrameCount = 0;
_battleSubFrame = 0;
} else {
_battleSubFrame++;
}
_battlePhase = _battlePhaseSequence[_phaseIndex].$1;
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness;
final colors = getThemeColors(widget.colorTheme, brightness);
// 특수 애니메이션 중이면 특별한 배경색 적용
final isSpecial = _currentSpecialAnimation != null;
final bgColor = isSpecial
? colors.backgroundColor.withValues(alpha: 0.95)
: colors.backgroundColor;
// 프레임 텍스트 결정
String frameText;
Color textColor = colors.textColor;
if (_isBattleMode && _battleComposer != null) {
// 새 배틀 시스템 사용 (배경 포함)
frameText = _battleComposer!.composeFrameWithBackground(
_battlePhase,
_battleSubFrame,
widget.monsterBaseName,
_environment,
_globalTick,
);
// 히트 페이즈면 몬스터 색상 변경
if (_battlePhase == BattlePhase.hit) {
final monsterColorCategory =
getMonsterColorCategory(widget.monsterBaseName);
textColor = getMonsterColors(monsterColorCategory).hit;
}
} else {
// 기존 레거시 시스템 사용
final frameIndex =
_currentFrame.clamp(0, _animationData.frames.length - 1);
frameText = _animationData.frames[frameIndex];
}
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(4),
border: isSpecial
? Border.all(color: colors.textColor.withValues(alpha: 0.5))
: null,
),
child: _isBattleMode
? LayoutBuilder(
builder: (context, constraints) {
// 60x8 프레임에 맞게 폰트 크기 자동 계산
// ASCII 문자 비율: 너비 = 높이 * 0.6 (모노스페이스)
final maxWidth = constraints.maxWidth;
final maxHeight = constraints.maxHeight;
// 60자 폭, 8줄 높이 기준
final fontSizeByWidth = maxWidth / 60 / 0.6;
final fontSizeByHeight = maxHeight / 8 / 1.2;
final fontSize = (fontSizeByWidth < fontSizeByHeight
? fontSizeByWidth
: fontSizeByHeight)
.clamp(6.0, 14.0);
return Center(
child: Text(
frameText,
style: TextStyle(
fontFamily: 'Courier',
fontSize: fontSize,
color: textColor,
height: 1.2,
letterSpacing: 0,
),
textAlign: TextAlign.left,
),
);
},
)
: Center(
child: Text(
frameText,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 10,
color: textColor,
height: 1.1,
letterSpacing: 0,
),
textAlign: TextAlign.center,
),
),
);
}
}