- walkingAnimation, townAnimation 4줄 → 3줄 통일 - character_frames.dart 모든 프레임 폭 6자로 통일 - _compose() 이펙트 Y 위치 동적 계산 (하드코딩 제거) - withShield() 3줄 캐릭터용으로 수정 (index 3 → index 1) - BattleComposer 캔버스 시스템 및 배경 합성 추가 - 무기 카테고리별 이펙트, 몬스터 크기/색상 시스템 구현
326 lines
10 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|