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:
JiWoong Sul
2025-12-13 18:22:50 +09:00
parent e30177e788
commit 598c25e4c9
14 changed files with 2052 additions and 355 deletions

View File

@@ -227,6 +227,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
colorTheme: _colorTheme,
onThemeCycle: _cycleColorTheme,
specialAnimation: _specialAnimation,
weaponName: state.equipment.weapon,
shieldName: state.equipment.shield,
characterLevel: state.traits.level,
monsterLevel: state.progress.currentTask.monsterLevel,
),
// 메인 3패널 영역

View File

@@ -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,
),
),
);
}
}

View File

@@ -16,6 +16,10 @@ class TaskProgressPanel extends StatelessWidget {
required this.colorTheme,
required this.onThemeCycle,
this.specialAnimation,
this.weaponName,
this.shieldName,
this.characterLevel,
this.monsterLevel,
});
final ProgressState progress;
@@ -27,6 +31,12 @@ class TaskProgressPanel extends StatelessWidget {
/// 특수 애니메이션 (레벨업, 퀘스트 완료 등)
final AsciiAnimationType? specialAnimation;
/// 장비 정보 (애니메이션 스타일 결정용)
final String? weaponName;
final String? shieldName;
final int? characterLevel;
final int? monsterLevel;
@override
Widget build(BuildContext context) {
return Container(
@@ -48,6 +58,10 @@ class TaskProgressPanel extends StatelessWidget {
monsterBaseName: progress.currentTask.monsterBaseName,
colorTheme: colorTheme,
specialAnimation: specialAnimation,
weaponName: weaponName,
shieldName: shieldName,
characterLevel: characterLevel,
monsterLevel: monsterLevel,
),
),
const SizedBox(height: 8),