fix(animation): ASCII 애니메이션 높낮이/공백 문제 수정
- walkingAnimation, townAnimation 4줄 → 3줄 통일 - character_frames.dart 모든 프레임 폭 6자로 통일 - _compose() 이펙트 Y 위치 동적 계산 (하드코딩 제거) - withShield() 3줄 캐릭터용으로 수정 (index 3 → index 1) - BattleComposer 캔버스 시스템 및 배경 합성 추가 - 무기 카테고리별 이펙트, 몬스터 크기/색상 시스템 구현
This commit is contained in:
@@ -4,6 +4,12 @@ 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 애니메이션 카드 위젯
|
||||
@@ -19,6 +25,10 @@ class AsciiAnimationCard extends StatefulWidget {
|
||||
this.monsterBaseName,
|
||||
this.colorTheme = AsciiColorTheme.green,
|
||||
this.specialAnimation,
|
||||
this.weaponName,
|
||||
this.shieldName,
|
||||
this.characterLevel,
|
||||
this.monsterLevel,
|
||||
});
|
||||
|
||||
final TaskType taskType;
|
||||
@@ -31,6 +41,18 @@ class AsciiAnimationCard extends StatefulWidget {
|
||||
/// 설정되면 일반 애니메이션 대신 표시
|
||||
final AsciiAnimationType? specialAnimation;
|
||||
|
||||
/// 현재 장착 무기 이름 (공격 스타일 결정용)
|
||||
final String? weaponName;
|
||||
|
||||
/// 현재 장착 방패 이름 (방패 표시용)
|
||||
final String? shieldName;
|
||||
|
||||
/// 캐릭터 레벨
|
||||
final int? characterLevel;
|
||||
|
||||
/// 몬스터 레벨 (몬스터 크기 결정용)
|
||||
final int? monsterLevel;
|
||||
|
||||
@override
|
||||
State<AsciiAnimationCard> createState() => _AsciiAnimationCardState();
|
||||
}
|
||||
@@ -41,6 +63,29 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
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();
|
||||
@@ -64,7 +109,10 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
}
|
||||
|
||||
if (oldWidget.taskType != widget.taskType ||
|
||||
oldWidget.monsterBaseName != widget.monsterBaseName) {
|
||||
oldWidget.monsterBaseName != widget.monsterBaseName ||
|
||||
oldWidget.weaponName != widget.weaponName ||
|
||||
oldWidget.shieldName != widget.shieldName ||
|
||||
oldWidget.monsterLevel != widget.monsterLevel) {
|
||||
_updateAnimation();
|
||||
}
|
||||
}
|
||||
@@ -74,6 +122,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
|
||||
// 특수 애니메이션이 있으면 우선 적용
|
||||
if (_currentSpecialAnimation != null) {
|
||||
_isBattleMode = false;
|
||||
_animationData = getAnimationData(_currentSpecialAnimation!);
|
||||
_currentFrame = 0;
|
||||
|
||||
@@ -99,26 +148,80 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
// 일반 애니메이션 처리
|
||||
final animationType = taskTypeToAnimation(widget.taskType);
|
||||
|
||||
// 전투 타입이면 몬스터 카테고리에 따라 다른 애니메이션 선택
|
||||
// 전투 타입이면 새 BattleComposer 시스템 사용
|
||||
if (animationType == AsciiAnimationType.battle) {
|
||||
final category = getMonsterCategory(widget.monsterBaseName);
|
||||
_animationData = getBattleAnimation(category);
|
||||
_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;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_currentFrame = 0;
|
||||
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);
|
||||
|
||||
_timer = Timer.periodic(
|
||||
Duration(milliseconds: _animationData.frameIntervalMs),
|
||||
(_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentFrame = (_currentFrame + 1) % _animationData.frames.length;
|
||||
});
|
||||
}
|
||||
},
|
||||
_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
|
||||
@@ -138,11 +241,35 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
? colors.backgroundColor.withValues(alpha: 0.95)
|
||||
: colors.backgroundColor;
|
||||
|
||||
// 프레임 인덱스가 범위를 벗어나지 않도록 보정
|
||||
final frameIndex = _currentFrame.clamp(0, _animationData.frames.length - 1);
|
||||
// 프레임 텍스트 결정
|
||||
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(8),
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -150,19 +277,49 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
? Border.all(color: colors.textColor.withValues(alpha: 0.5))
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_animationData.frames[frameIndex],
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
color: colors.textColor,
|
||||
height: 1.1,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user