- AsciiLayer에 opacity 필드 추가 - AsciiCanvasPainter에서 레이어 투명도 렌더링 지원 - 배경 레이어 50% 투명으로 캐릭터 부각 - 모든 무기 이펙트 3줄→5줄로 확장 - 몬스터 공격 이펙트 5줄로 확장
571 lines
17 KiB
Dart
571 lines
17 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/canvas/ascii_canvas_widget.dart';
|
|
import 'package:askiineverdie/src/core/animation/canvas/ascii_layer.dart';
|
|
import 'package:askiineverdie/src/core/animation/canvas/canvas_battle_composer.dart';
|
|
import 'package:askiineverdie/src/core/animation/canvas/canvas_special_composer.dart';
|
|
import 'package:askiineverdie/src/core/animation/canvas/canvas_town_composer.dart';
|
|
import 'package:askiineverdie/src/core/animation/canvas/canvas_walking_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/combat_event.dart';
|
|
import 'package:askiineverdie/src/core/model/game_state.dart';
|
|
|
|
/// 애니메이션 모드
|
|
enum AnimationMode {
|
|
battle, // 전투
|
|
walking, // 걷기
|
|
town, // 마을/상점
|
|
special, // 특수 이벤트
|
|
}
|
|
|
|
/// ASCII 애니메이션 카드 위젯 (전체 Canvas 기반)
|
|
///
|
|
/// 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,
|
|
this.latestCombatEvent,
|
|
this.raceId,
|
|
});
|
|
|
|
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;
|
|
|
|
/// 최근 전투 이벤트 (애니메이션 동기화용)
|
|
final CombatEvent? latestCombatEvent;
|
|
|
|
/// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션)
|
|
final String? raceId;
|
|
|
|
@override
|
|
State<AsciiAnimationCard> createState() => _AsciiAnimationCardState();
|
|
}
|
|
|
|
class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|
Timer? _timer;
|
|
int _currentFrame = 0;
|
|
AsciiAnimationType? _currentSpecialAnimation;
|
|
|
|
// 애니메이션 모드
|
|
AnimationMode _animationMode = AnimationMode.walking;
|
|
|
|
// Composer 인스턴스들
|
|
CanvasBattleComposer? _battleComposer;
|
|
CanvasWalkingComposer? _walkingComposer;
|
|
CanvasTownComposer? _townComposer;
|
|
final _specialComposer = const CanvasSpecialComposer();
|
|
|
|
// 전투 애니메이션 상태
|
|
BattlePhase _battlePhase = BattlePhase.idle;
|
|
int _battleSubFrame = 0;
|
|
|
|
// 글로벌 틱 (배경 스크롤용)
|
|
int _globalTick = 0;
|
|
|
|
// 특수 애니메이션 틱 카운터 (프레임 간격 계산용)
|
|
int _specialTick = 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;
|
|
|
|
// 전투 이벤트 동기화용 (Phase 5)
|
|
int? _lastEventTimestamp;
|
|
bool _showCriticalEffect = false;
|
|
bool _showBlockEffect = false;
|
|
bool _showParryEffect = false;
|
|
bool _showSkillEffect = false;
|
|
|
|
// 공격 속도 기반 동적 페이즈 프레임 수 (Phase 6)
|
|
int _eventDrivenPhaseFrames = 0;
|
|
bool _isEventDrivenPhase = false;
|
|
|
|
// 공격자 타입 (Phase 7: 공격자별 위치 분리)
|
|
AttackerType _currentAttacker = AttackerType.none;
|
|
|
|
// 특수 애니메이션 프레임 수는 ascii_animation_type.dart의
|
|
// specialAnimationFrameCounts 상수 사용
|
|
|
|
@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 {
|
|
// 재개 시: specialAnimation 동기화 (isPaused와 동시에 변경될 수 있음)
|
|
// 예: 부활 시 isPaused가 true→false로 바뀌면서 동시에
|
|
// specialAnimation이 null→resurrection으로 변경됨
|
|
if (widget.specialAnimation != _currentSpecialAnimation) {
|
|
_currentSpecialAnimation = widget.specialAnimation;
|
|
_updateAnimation(); // _updateAnimation이 타이머 재시작도 처리함
|
|
return;
|
|
}
|
|
// 일반 재개: 애니메이션 재시작 (현재 프레임 유지)
|
|
_restartTimer();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 특수 애니메이션이 변경되었으면 업데이트
|
|
if (oldWidget.specialAnimation != widget.specialAnimation) {
|
|
_currentSpecialAnimation = widget.specialAnimation;
|
|
_updateAnimation();
|
|
return;
|
|
}
|
|
|
|
// 특수 애니메이션이 활성화되어 있으면 일반 업데이트 무시
|
|
if (_currentSpecialAnimation != null) {
|
|
return;
|
|
}
|
|
|
|
// 전투 이벤트 동기화 (Phase 5)
|
|
if (widget.latestCombatEvent != null &&
|
|
widget.latestCombatEvent!.timestamp != _lastEventTimestamp) {
|
|
_handleCombatEvent(widget.latestCombatEvent!);
|
|
}
|
|
|
|
if (oldWidget.taskType != widget.taskType ||
|
|
oldWidget.monsterBaseName != widget.monsterBaseName ||
|
|
oldWidget.weaponName != widget.weaponName ||
|
|
oldWidget.shieldName != widget.shieldName ||
|
|
oldWidget.monsterLevel != widget.monsterLevel ||
|
|
oldWidget.raceId != widget.raceId) {
|
|
_updateAnimation();
|
|
}
|
|
}
|
|
|
|
/// 전투 이벤트에 따라 애니메이션 페이즈 강제 전환 (Phase 5)
|
|
void _handleCombatEvent(CombatEvent event) {
|
|
_lastEventTimestamp = event.timestamp;
|
|
|
|
// 전투 모드가 아니면 무시
|
|
if (_animationMode != AnimationMode.battle) return;
|
|
|
|
// 이벤트 타입에 따라 페이즈 및 효과 결정
|
|
final (
|
|
targetPhase,
|
|
isCritical,
|
|
isBlock,
|
|
isParry,
|
|
isSkill,
|
|
) = switch (event.type) {
|
|
// 플레이어 공격 → prepare 페이즈부터 시작 (준비 동작 표시)
|
|
CombatEventType.playerAttack => (
|
|
BattlePhase.prepare,
|
|
event.isCritical,
|
|
false,
|
|
false,
|
|
false,
|
|
),
|
|
// 스킬 사용 → prepare 페이즈부터 시작 + 스킬 이펙트
|
|
CombatEventType.playerSkill => (
|
|
BattlePhase.prepare,
|
|
event.isCritical,
|
|
false,
|
|
false,
|
|
true,
|
|
),
|
|
|
|
// 몬스터 공격 → prepare 페이즈부터 시작
|
|
CombatEventType.monsterAttack => (
|
|
BattlePhase.prepare,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
),
|
|
// 블록 → hit 페이즈 + 블록 이펙트
|
|
CombatEventType.playerBlock => (
|
|
BattlePhase.hit,
|
|
false,
|
|
true,
|
|
false,
|
|
false,
|
|
),
|
|
// 패리 → hit 페이즈 + 패리 이펙트
|
|
CombatEventType.playerParry => (
|
|
BattlePhase.hit,
|
|
false,
|
|
false,
|
|
true,
|
|
false,
|
|
),
|
|
|
|
// 회피 → recover 페이즈 (빠른 회피 동작)
|
|
CombatEventType.playerEvade => (
|
|
BattlePhase.recover,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
),
|
|
CombatEventType.monsterEvade => (
|
|
BattlePhase.idle,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
),
|
|
|
|
// 회복/버프 → idle 페이즈 유지
|
|
CombatEventType.playerHeal => (
|
|
BattlePhase.idle,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
),
|
|
CombatEventType.playerBuff => (
|
|
BattlePhase.idle,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
),
|
|
|
|
// DOT 틱 → attack 페이즈 (지속 피해)
|
|
CombatEventType.dotTick => (
|
|
BattlePhase.attack,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
),
|
|
|
|
// 물약 사용 → idle 페이즈 유지
|
|
CombatEventType.playerPotion => (
|
|
BattlePhase.idle,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
),
|
|
|
|
// 물약 드랍 → idle 페이즈 유지
|
|
CombatEventType.potionDrop => (
|
|
BattlePhase.idle,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
),
|
|
};
|
|
|
|
setState(() {
|
|
_battlePhase = targetPhase;
|
|
_battleSubFrame = 0;
|
|
_phaseFrameCount = 0;
|
|
_showCriticalEffect = isCritical;
|
|
_showBlockEffect = isBlock;
|
|
_showParryEffect = isParry;
|
|
_showSkillEffect = isSkill;
|
|
|
|
// 페이즈 인덱스 동기화
|
|
_phaseIndex = _battlePhaseSequence.indexWhere((p) => p.$1 == targetPhase);
|
|
if (_phaseIndex < 0) _phaseIndex = 0;
|
|
|
|
// 공격 속도에 따른 동적 페이즈 프레임 수 계산 (Phase 6)
|
|
// 200ms tick 기준으로 프레임 수 계산 (최소 2, 최대 10)
|
|
if (event.attackDelayMs != null && event.attackDelayMs! > 0) {
|
|
_eventDrivenPhaseFrames = (event.attackDelayMs! ~/ 200).clamp(2, 10);
|
|
_isEventDrivenPhase = true;
|
|
} else {
|
|
_isEventDrivenPhase = false;
|
|
}
|
|
|
|
// 공격자 타입 결정 (Phase 7: 공격자별 위치 분리)
|
|
_currentAttacker = switch (event.type) {
|
|
CombatEventType.playerAttack ||
|
|
CombatEventType.playerSkill => AttackerType.player,
|
|
CombatEventType.monsterAttack => AttackerType.monster,
|
|
_ => AttackerType.none,
|
|
};
|
|
});
|
|
}
|
|
|
|
/// 현재 상태를 유지하면서 타이머만 재시작
|
|
void _restartTimer() {
|
|
_timer?.cancel();
|
|
_startTimer();
|
|
}
|
|
|
|
/// 타이머 시작
|
|
void _startTimer() {
|
|
const tickInterval = Duration(milliseconds: 200);
|
|
|
|
_timer = Timer.periodic(tickInterval, (_) {
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_globalTick++;
|
|
|
|
if (_animationMode == AnimationMode.special) {
|
|
_specialTick++;
|
|
// 특수 애니메이션 프레임 간격 계산 (200ms tick 기준)
|
|
// 예: resurrection 600ms → 600/200 = 3 tick마다 1 프레임
|
|
final frameInterval =
|
|
(specialAnimationFrameIntervals[_currentSpecialAnimation] ?? 200) ~/
|
|
200;
|
|
if (_specialTick >= frameInterval) {
|
|
_specialTick = 0;
|
|
_currentFrame++;
|
|
final maxFrames =
|
|
specialAnimationFrameCounts[_currentSpecialAnimation] ?? 5;
|
|
// 마지막 프레임에 도달하면 특수 애니메이션 종료
|
|
if (_currentFrame >= maxFrames) {
|
|
_currentSpecialAnimation = null;
|
|
_updateAnimation();
|
|
}
|
|
}
|
|
} else if (_animationMode == AnimationMode.battle) {
|
|
_advanceBattleFrame();
|
|
}
|
|
// walking, town은 globalTick만 증가하면 됨
|
|
});
|
|
});
|
|
}
|
|
|
|
void _updateAnimation() {
|
|
_timer?.cancel();
|
|
|
|
// 특수 애니메이션이 있으면 우선 적용
|
|
if (_currentSpecialAnimation != null) {
|
|
_animationMode = AnimationMode.special;
|
|
_currentFrame = 0;
|
|
_specialTick = 0;
|
|
|
|
// 특수 애니메이션은 게임 일시정지와 무관하게 항상 재생
|
|
_startTimer();
|
|
return;
|
|
}
|
|
|
|
// 일반 애니메이션 처리
|
|
final animationType = taskTypeToAnimation(widget.taskType);
|
|
|
|
switch (animationType) {
|
|
case AsciiAnimationType.battle:
|
|
_animationMode = AnimationMode.battle;
|
|
_setupBattleComposer();
|
|
_battlePhase = BattlePhase.idle;
|
|
_battleSubFrame = 0;
|
|
_phaseIndex = 0;
|
|
_phaseFrameCount = 0;
|
|
|
|
case AsciiAnimationType.town:
|
|
_animationMode = AnimationMode.town;
|
|
_townComposer = CanvasTownComposer(raceId: widget.raceId);
|
|
|
|
case AsciiAnimationType.walking:
|
|
_animationMode = AnimationMode.walking;
|
|
_walkingComposer = CanvasWalkingComposer(raceId: widget.raceId);
|
|
|
|
default:
|
|
_animationMode = AnimationMode.walking;
|
|
_walkingComposer = CanvasWalkingComposer(raceId: widget.raceId);
|
|
}
|
|
|
|
// 일시정지 상태면 타이머 시작하지 않음
|
|
if (widget.isPaused) return;
|
|
|
|
_startTimer();
|
|
}
|
|
|
|
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 = CanvasBattleComposer(
|
|
weaponCategory: weaponCategory,
|
|
hasShield: hasShield,
|
|
monsterCategory: monsterCategory,
|
|
monsterSize: monsterSize,
|
|
raceId: widget.raceId,
|
|
);
|
|
|
|
// 환경 타입 추론
|
|
_environment = inferEnvironment(
|
|
widget.taskType.name,
|
|
widget.monsterBaseName,
|
|
);
|
|
}
|
|
|
|
void _advanceBattleFrame() {
|
|
_phaseFrameCount++;
|
|
final currentPhase = _battlePhaseSequence[_phaseIndex];
|
|
|
|
// 현재 페이즈의 프레임 수 결정 (Phase 6)
|
|
// 이벤트 기반 페이즈일 경우 공격 속도에 따른 동적 프레임 수 사용
|
|
final targetFrames = _isEventDrivenPhase
|
|
? _eventDrivenPhaseFrames
|
|
: currentPhase.$2;
|
|
|
|
// 현재 페이즈의 프레임 수를 초과하면 다음 페이즈로
|
|
if (_phaseFrameCount >= targetFrames) {
|
|
_phaseIndex = (_phaseIndex + 1) % _battlePhaseSequence.length;
|
|
_phaseFrameCount = 0;
|
|
_battleSubFrame = 0;
|
|
// 이펙트 리셋 (페이즈 전환 시)
|
|
_showCriticalEffect = false;
|
|
_showBlockEffect = false;
|
|
_showParryEffect = false;
|
|
_showSkillEffect = false;
|
|
// 공격자 타입 및 이벤트 기반 페이즈 리셋 (idle 페이즈 진입 시에만)
|
|
// 공격 사이클(prepare→attack→hit→recover) 동안 유지 (Bug fix)
|
|
if (_battlePhaseSequence[_phaseIndex].$1 == BattlePhase.idle) {
|
|
_currentAttacker = AttackerType.none;
|
|
_isEventDrivenPhase = false;
|
|
}
|
|
} else {
|
|
_battleSubFrame++;
|
|
}
|
|
|
|
_battlePhase = _battlePhaseSequence[_phaseIndex].$1;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
/// 현재 애니메이션 레이어 생성
|
|
List<AsciiLayer> _composeLayers() {
|
|
return switch (_animationMode) {
|
|
AnimationMode.battle =>
|
|
_battleComposer?.composeLayers(
|
|
_battlePhase,
|
|
_battleSubFrame,
|
|
widget.monsterBaseName,
|
|
_environment,
|
|
_globalTick,
|
|
attacker: _currentAttacker,
|
|
) ??
|
|
[AsciiLayer.empty()],
|
|
AnimationMode.walking =>
|
|
_walkingComposer?.composeLayers(_globalTick) ?? [AsciiLayer.empty()],
|
|
AnimationMode.town =>
|
|
_townComposer?.composeLayers(_globalTick) ?? [AsciiLayer.empty()],
|
|
AnimationMode.special => _specialComposer.composeLayers(
|
|
_currentSpecialAnimation ?? AsciiAnimationType.levelUp,
|
|
_currentFrame,
|
|
_globalTick,
|
|
),
|
|
};
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// 검정 배경 위에 배경 레이어(50% 투명)가 그려짐
|
|
const bgColor = AsciiColors.background;
|
|
|
|
// 테두리 효과 결정 (전투 이벤트 또는 특수 애니메이션)
|
|
final isSpecial = _currentSpecialAnimation != null;
|
|
Border? borderEffect;
|
|
if (_showCriticalEffect) {
|
|
// 크리티컬 히트: 노란색 테두리
|
|
borderEffect = Border.all(
|
|
color: Colors.yellow.withValues(alpha: 0.8),
|
|
width: 2,
|
|
);
|
|
} else if (_showBlockEffect) {
|
|
// 블록 (방패 방어): 파란색 테두리
|
|
borderEffect = Border.all(
|
|
color: Colors.blue.withValues(alpha: 0.8),
|
|
width: 2,
|
|
);
|
|
} else if (_showParryEffect) {
|
|
// 패리 (무기 쳐내기): 주황색 테두리
|
|
borderEffect = Border.all(
|
|
color: Colors.orange.withValues(alpha: 0.8),
|
|
width: 2,
|
|
);
|
|
} else if (_showSkillEffect) {
|
|
// 스킬 사용: 마젠타 테두리
|
|
borderEffect = Border.all(
|
|
color: Colors.purple.withValues(alpha: 0.8),
|
|
width: 2,
|
|
);
|
|
} else if (isSpecial) {
|
|
// 특수 애니메이션: 시안 테두리
|
|
borderEffect = Border.all(
|
|
color: AsciiColors.positive.withValues(alpha: 0.5),
|
|
);
|
|
}
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: bgColor,
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: borderEffect,
|
|
),
|
|
child: AsciiCanvasWidget(layers: _composeLayers()),
|
|
);
|
|
}
|
|
}
|