- ascii_colors.dart 생성 - 흰색(object): 캐릭터, 몬스터, 아이템 - 시안(positive): 힐, 버프, 레벨업, 획득 - 마젠타(negative): 데미지, 디버프, 사망, 손실 - 검정(background): 배경 - 테마 선택 기능 제거 - AsciiAnimationCard: colorTheme 파라미터 제거, 고정 색상 사용 - TaskProgressPanel: 테마 버튼 제거 - GamePlayScreen: 테마 관련 상태/메서드 제거 - 이펙트 색상 시스템 업데이트 - '*' (히트) → 마젠타 - '!' '+' (강조/버프) → 시안 - '~' (디버프) → 마젠타
439 lines
13 KiB
Dart
439 lines
13 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_size.dart';
|
|
import 'package:askiineverdie/src/core/animation/weapon_category.dart';
|
|
import 'package:askiineverdie/src/core/constants/ascii_colors.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,
|
|
this.isPaused = false,
|
|
});
|
|
|
|
final TaskType taskType;
|
|
|
|
/// 일시정지 상태 (true면 애니메이션 정지)
|
|
final bool isPaused;
|
|
|
|
/// 전투 중인 몬스터 기본 이름 (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.isPaused != widget.isPaused) {
|
|
if (widget.isPaused) {
|
|
_timer?.cancel();
|
|
_timer = null;
|
|
} else {
|
|
// 재개: 애니메이션 재시작 (현재 프레임 유지)
|
|
_restartTimer();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 특수 애니메이션이 변경되었으면 업데이트
|
|
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 _restartTimer() {
|
|
_timer?.cancel();
|
|
|
|
// 특수 애니메이션 타이머 재시작
|
|
if (_currentSpecialAnimation != null) {
|
|
_timer = Timer.periodic(
|
|
Duration(milliseconds: _animationData.frameIntervalMs),
|
|
(_) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_currentFrame++;
|
|
if (_currentFrame >= _animationData.frames.length) {
|
|
_currentSpecialAnimation = null;
|
|
_updateAnimation();
|
|
}
|
|
});
|
|
}
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 전투 모드 타이머 재시작
|
|
if (_isBattleMode) {
|
|
_timer = Timer.periodic(
|
|
const Duration(milliseconds: 200),
|
|
(_) => _advanceBattleFrame(),
|
|
);
|
|
} else {
|
|
// 일반 애니메이션 타이머 재시작
|
|
_timer = Timer.periodic(
|
|
Duration(milliseconds: _animationData.frameIntervalMs),
|
|
(_) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_currentFrame =
|
|
(_currentFrame + 1) % _animationData.frames.length;
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
void _updateAnimation() {
|
|
_timer?.cancel();
|
|
|
|
// 특수 애니메이션이 있으면 우선 적용
|
|
if (_currentSpecialAnimation != null) {
|
|
_isBattleMode = false;
|
|
_animationData = getAnimationData(_currentSpecialAnimation!);
|
|
_currentFrame = 0;
|
|
|
|
// 일시정지 상태면 타이머 시작하지 않음
|
|
if (widget.isPaused) return;
|
|
|
|
// 특수 애니메이션은 한 번 재생 후 종료
|
|
_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;
|
|
|
|
// 일시정지 상태면 타이머 시작하지 않음
|
|
if (widget.isPaused) return;
|
|
|
|
_timer = Timer.periodic(
|
|
const Duration(milliseconds: 200),
|
|
(_) => _advanceBattleFrame(),
|
|
);
|
|
} else {
|
|
_isBattleMode = false;
|
|
_animationData = getAnimationData(animationType);
|
|
_currentFrame = 0;
|
|
|
|
// 일시정지 상태면 타이머 시작하지 않음
|
|
if (widget.isPaused) return;
|
|
|
|
_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();
|
|
}
|
|
|
|
/// 이펙트 문자에 색상을 적용한 TextSpan 생성
|
|
TextSpan _buildColoredTextSpan(String text, TextStyle baseStyle) {
|
|
final spans = <TextSpan>[];
|
|
final buffer = StringBuffer();
|
|
|
|
// 이펙트 문자 정의
|
|
const effectChars = {'*', '!', '=', '>', '<', '~'};
|
|
|
|
for (var i = 0; i < text.length; i++) {
|
|
final char = text[i];
|
|
|
|
if (effectChars.contains(char)) {
|
|
// 버퍼에 쌓인 일반 텍스트 추가
|
|
if (buffer.isNotEmpty) {
|
|
spans.add(TextSpan(text: buffer.toString(), style: baseStyle));
|
|
buffer.clear();
|
|
}
|
|
|
|
// 이펙트 문자에 색상 적용
|
|
final effectColor = _getEffectColor(char);
|
|
spans.add(TextSpan(
|
|
text: char,
|
|
style: baseStyle.copyWith(color: effectColor),
|
|
));
|
|
} else {
|
|
buffer.write(char);
|
|
}
|
|
}
|
|
|
|
// 남은 일반 텍스트 추가
|
|
if (buffer.isNotEmpty) {
|
|
spans.add(TextSpan(text: buffer.toString(), style: baseStyle));
|
|
}
|
|
|
|
return TextSpan(children: spans);
|
|
}
|
|
|
|
/// 이펙트 문자별 색상 반환 (Phase 7: 4색 팔레트)
|
|
Color _getEffectColor(String char) {
|
|
return switch (char) {
|
|
'*' => AsciiColors.negative, // 히트/폭발 (마젠타)
|
|
'!' => AsciiColors.positive, // 강조 (시안)
|
|
'=' || '>' || '<' => AsciiColors.positive, // 슬래시/찌르기 (시안)
|
|
'~' => AsciiColors.negative, // 물결/디버프 (마젠타)
|
|
'+' => AsciiColors.positive, // 회복/버프 (시안)
|
|
_ => AsciiColors.object, // 오브젝트 (흰색)
|
|
};
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Phase 7: 고정 4색 팔레트 사용 (colorTheme 무시)
|
|
const bgColor = AsciiColors.background;
|
|
const textColor = AsciiColors.object;
|
|
|
|
// 프레임 텍스트 결정
|
|
String frameText;
|
|
|
|
if (_isBattleMode && _battleComposer != null) {
|
|
// 새 배틀 시스템 사용 (배경 포함)
|
|
frameText = _battleComposer!.composeFrameWithBackground(
|
|
_battlePhase,
|
|
_battleSubFrame,
|
|
widget.monsterBaseName,
|
|
_environment,
|
|
_globalTick,
|
|
);
|
|
// 이펙트는 텍스트 자체로 구분 (*, !, =, ~ 등)
|
|
// 전체 색상 변경 제거 - 기본 테마 색상 유지
|
|
} else {
|
|
// 기존 레거시 시스템 사용
|
|
final frameIndex =
|
|
_currentFrame.clamp(0, _animationData.frames.length - 1);
|
|
frameText = _animationData.frames[frameIndex];
|
|
}
|
|
|
|
// 특수 애니메이션 중이면 테두리 표시
|
|
final isSpecial = _currentSpecialAnimation != null;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: bgColor,
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: isSpecial
|
|
? Border.all(color: AsciiColors.positive.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: RichText(
|
|
text: _buildColoredTextSpan(
|
|
frameText,
|
|
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.left,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|