feat(canvas): Canvas 기반 ASCII 애니메이션 렌더러 구현

- JetBrains Mono 폰트 번들링 (Android/iOS 호환성)
- Paragraph 캐싱으로 GC 압박 최소화 (최대 256개 캐시)
- shouldRepaint layerVersion 기반 최적화
- willChange 동적 설정으로 메모리 절약
- 레이어 기반 합성 구조 (배경/캐릭터/몬스터/이펙트)
- hp_mp_bar 몬스터 HP 숫자 오버플로우 수정
This commit is contained in:
JiWoong Sul
2025-12-20 07:49:11 +09:00
parent cf8fdaecde
commit c07f77a02f
18 changed files with 2224 additions and 277 deletions

View File

@@ -453,7 +453,24 @@ class _GamePlayScreenState extends State<GamePlayScreen>
deathInfo: state.deathInfo!,
traits: state.traits,
onResurrect: () async {
// 1. 부활 처리 (HP/MP 회복, 장비 구매 - 게임 재개 없음)
await widget.controller.resurrect();
// 2. 부활 애니메이션 재생
setState(() {
_specialAnimation = AsciiAnimationType.resurrection;
});
// 3. 애니메이션 종료 후 게임 재개 (5프레임 × 600ms = 3초)
Future.delayed(const Duration(milliseconds: 3000), () async {
if (mounted) {
setState(() {
_specialAnimation = null;
});
// 부활 후 게임 재개 (새 루프 시작)
await widget.controller.resumeAfterResurrection();
}
});
},
),
],

View File

@@ -152,9 +152,10 @@ class GameSessionController extends ChangeNotifier {
notifyListeners();
}
/// 플레이어 부활 처리
/// 플레이어 부활 처리 (상태만 업데이트, 게임 재개는 별도로)
///
/// HP/MP 회복, 빈 슬롯에 장비 자동 구매, 게임 재개
/// HP/MP 회복, 빈 슬롯에 장비 자동 구매
/// 게임 재개는 resumeAfterResurrection()으로 별도 호출 필요
Future<void> resurrect() async {
if (_state == null || !_state!.isDead) return;
@@ -164,11 +165,24 @@ class GameSessionController extends ChangeNotifier {
final resurrectedState = resurrectionService.processResurrection(_state!);
// 상태 업데이트 (게임 재개 없이)
_state = resurrectedState;
_status = GameSessionStatus.idle; // 사망 상태 해제
// 저장
await saveManager.saveState(resurrectedState);
notifyListeners();
}
/// 부활 후 게임 재개
///
/// resurrect() 호출 후 애니메이션이 끝난 뒤 호출
Future<void> resumeAfterResurrection() async {
if (_state == null) return;
// 게임 재개
await startNew(resurrectedState, cheatsEnabled: _cheatsEnabled, isNewGame: false);
await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false);
}
/// 사망 상태 여부

View File

@@ -5,7 +5,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/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';
@@ -13,7 +18,15 @@ 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';
/// ASCII 애니메이션 카드 위젯
/// 애니메이션 모드
enum AnimationMode {
battle, // 전투
walking, // 걷기
town, // 마을/상점
special, // 특수 이벤트
}
/// ASCII 애니메이션 카드 위젯 (전체 Canvas 기반)
///
/// TaskType에 따라 다른 애니메이션을 표시.
/// 전투 시 몬스터 이름에 따라 다른 애니메이션 선택.
@@ -69,14 +82,20 @@ class AsciiAnimationCard extends StatefulWidget {
class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
Timer? _timer;
int _currentFrame = 0;
late AsciiAnimationData _animationData;
AsciiAnimationType? _currentSpecialAnimation;
// 애니메이션 모드
AnimationMode _animationMode = AnimationMode.walking;
// Composer 인스턴스들
CanvasBattleComposer? _battleComposer;
final _walkingComposer = const CanvasWalkingComposer();
final _townComposer = const CanvasTownComposer();
final _specialComposer = const CanvasSpecialComposer();
// 전투 애니메이션 상태
bool _isBattleMode = false;
BattlePhase _battlePhase = BattlePhase.idle;
int _battleSubFrame = 0;
BattleComposer? _battleComposer;
// 글로벌 틱 (배경 스크롤용)
int _globalTick = 0;
@@ -99,6 +118,14 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
int? _lastEventTimestamp;
bool _showCriticalEffect = false;
// 특수 애니메이션 프레임 수
static const _specialAnimationFrameCounts = {
AsciiAnimationType.levelUp: 5,
AsciiAnimationType.questComplete: 4,
AsciiAnimationType.actComplete: 4,
AsciiAnimationType.resurrection: 5,
};
@override
void initState() {
super.initState();
@@ -153,7 +180,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_lastEventTimestamp = event.timestamp;
// 전투 모드가 아니면 무시
if (!_isBattleMode) return;
if (_animationMode != AnimationMode.battle) return;
// 이벤트 타입에 따라 페이즈 강제 전환
final (targetPhase, isCritical) = switch (event.type) {
@@ -190,46 +217,34 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
/// 현재 상태를 유지하면서 타이머만 재시작
void _restartTimer() {
_timer?.cancel();
_startTimer();
}
// 특수 애니메이션 타이머 시작
if (_currentSpecialAnimation != null) {
_timer = Timer.periodic(
Duration(milliseconds: _animationData.frameIntervalMs),
(_) {
if (mounted) {
setState(() {
_currentFrame++;
if (_currentFrame >= _animationData.frames.length) {
_currentSpecialAnimation = null;
_updateAnimation();
}
});
}
},
);
return;
}
/// 타이머 시작
void _startTimer() {
const tickInterval = Duration(milliseconds: 200);
// 전투 모드 타이머 재시작
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;
});
_timer = Timer.periodic(tickInterval, (_) {
if (!mounted) return;
setState(() {
_globalTick++;
if (_animationMode == AnimationMode.special) {
_currentFrame++;
final maxFrames =
_specialAnimationFrameCounts[_currentSpecialAnimation] ?? 5;
// 마지막 프레임에 도달하면 특수 애니메이션 종료
if (_currentFrame >= maxFrames) {
_currentSpecialAnimation = null;
_updateAnimation();
}
},
);
}
} else if (_animationMode == AnimationMode.battle) {
_advanceBattleFrame();
}
// walking, town은 globalTick만 증가하면 됨
});
});
}
void _updateAnimation() {
@@ -237,71 +252,40 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
// 특수 애니메이션이 있으면 우선 적용
if (_currentSpecialAnimation != null) {
_isBattleMode = false;
_animationData = getAnimationData(_currentSpecialAnimation!);
_animationMode = AnimationMode.special;
_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();
}
});
}
},
);
// 특수 애니메이션은 게임 일시정지와 무관하게 항상 재생
_startTimer();
return;
}
// 일반 애니메이션 처리
final animationType = taskTypeToAnimation(widget.taskType);
// 전투 타입이면 새 BattleComposer 시스템 사용
if (animationType == AsciiAnimationType.battle) {
_isBattleMode = true;
_setupBattleComposer();
_battlePhase = BattlePhase.idle;
_battleSubFrame = 0;
_phaseIndex = 0;
_phaseFrameCount = 0;
switch (animationType) {
case AsciiAnimationType.battle:
_animationMode = AnimationMode.battle;
_setupBattleComposer();
_battlePhase = BattlePhase.idle;
_battleSubFrame = 0;
_phaseIndex = 0;
_phaseFrameCount = 0;
// 일시정지 상태면 타이머 시작하지 않음
if (widget.isPaused) return;
case AsciiAnimationType.town:
_animationMode = AnimationMode.town;
_timer = Timer.periodic(
const Duration(milliseconds: 200),
(_) => _advanceBattleFrame(),
);
} else {
_isBattleMode = false;
_animationData = getAnimationData(animationType);
_currentFrame = 0;
case AsciiAnimationType.walking:
_animationMode = AnimationMode.walking;
// 일시정지 상태면 타이머 시작하지 않음
if (widget.isPaused) return;
_timer = Timer.periodic(
Duration(milliseconds: _animationData.frameIntervalMs),
(_) {
if (mounted) {
setState(() {
_currentFrame =
(_currentFrame + 1) % _animationData.frames.length;
});
}
},
);
default:
_animationMode = AnimationMode.walking;
}
// 일시정지 상태면 타이머 시작하지 않음
if (widget.isPaused) return;
_startTimer();
}
void _setupBattleComposer() {
@@ -311,7 +295,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
final monsterCategory = getMonsterCategory(widget.monsterBaseName);
final monsterSize = getMonsterSize(widget.monsterLevel);
_battleComposer = BattleComposer(
_battleComposer = CanvasBattleComposer(
weaponCategory: weaponCategory,
hasShield: hasShield,
monsterCategory: monsterCategory,
@@ -326,28 +310,21 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
}
void _advanceBattleFrame() {
if (!mounted) return;
_phaseFrameCount++;
final currentPhase = _battlePhaseSequence[_phaseIndex];
setState(() {
// 글로벌 틱 증가 (배경 스크롤용)
_globalTick++;
// 현재 페이즈의 프레임 수를 초과하면 다음 페이즈로
if (_phaseFrameCount >= currentPhase.$2) {
_phaseIndex = (_phaseIndex + 1) % _battlePhaseSequence.length;
_phaseFrameCount = 0;
_battleSubFrame = 0;
// 크리티컬 이펙트 리셋 (페이즈 전환 시)
_showCriticalEffect = false;
} else {
_battleSubFrame++;
}
_phaseFrameCount++;
final currentPhase = _battlePhaseSequence[_phaseIndex];
// 현재 페이즈의 프레임 수를 초과하면 다음 페이즈로
if (_phaseFrameCount >= currentPhase.$2) {
_phaseIndex = (_phaseIndex + 1) % _battlePhaseSequence.length;
_phaseFrameCount = 0;
_battleSubFrame = 0;
// 크리티컬 이펙트 리셋 (페이즈 전환 시)
_showCriticalEffect = false;
} else {
_battleSubFrame++;
}
_battlePhase = _battlePhaseSequence[_phaseIndex].$1;
});
_battlePhase = _battlePhaseSequence[_phaseIndex].$1;
}
@override
@@ -356,52 +333,24 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
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, // 오브젝트 (흰색)
/// 현재 애니메이션 레이어 생성
List<AsciiLayer> _composeLayers() {
return switch (_animationMode) {
AnimationMode.battle => _battleComposer?.composeLayers(
_battlePhase,
_battleSubFrame,
widget.monsterBaseName,
_environment,
_globalTick,
) ??
[AsciiLayer.empty()],
AnimationMode.walking => _walkingComposer.composeLayers(_globalTick),
AnimationMode.town => _townComposer.composeLayers(_globalTick),
AnimationMode.special => _specialComposer.composeLayers(
_currentSpecialAnimation ?? AsciiAnimationType.levelUp,
_currentFrame,
_globalTick,
),
};
}
@@ -409,38 +358,18 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
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;
Border? borderEffect;
if (_showCriticalEffect) {
// 크리티컬 히트: 노란색 테두리 (Phase 5)
borderEffect = Border.all(color: Colors.yellow.withValues(alpha: 0.8), width: 2);
borderEffect =
Border.all(color: Colors.yellow.withValues(alpha: 0.8), width: 2);
} else if (isSpecial) {
// 특수 애니메이션: 시안 테두리
borderEffect = Border.all(color: AsciiColors.positive.withValues(alpha: 0.5));
borderEffect =
Border.all(color: AsciiColors.positive.withValues(alpha: 0.5));
}
return Container(
@@ -450,51 +379,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
borderRadius: BorderRadius.circular(4),
border: borderEffect,
),
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,
),
),
child: AsciiCanvasWidget(layers: _composeLayers()),
);
}
}

View File

@@ -50,41 +50,43 @@ class DeathOverlay extends StatelessWidget {
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 사망 타이틀
_buildDeathTitle(context),
const SizedBox(height: 16),
// 캐릭터 정보
_buildCharacterInfo(context),
const SizedBox(height: 16),
// 사망 원인
_buildDeathCause(context),
const SizedBox(height: 24),
// 구분선
Divider(color: colorScheme.outlineVariant),
const SizedBox(height: 16),
// 상실 정보
_buildLossInfo(context),
// 전투 로그 (있는 경우만 표시)
if (deathInfo.lastCombatEvents.isNotEmpty) ...[
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 사망 타이틀
_buildDeathTitle(context),
const SizedBox(height: 16),
// 캐릭터 정보
_buildCharacterInfo(context),
const SizedBox(height: 16),
// 사망 원인
_buildDeathCause(context),
const SizedBox(height: 24),
// 구분선
Divider(color: colorScheme.outlineVariant),
const SizedBox(height: 8),
_buildCombatLog(context),
const SizedBox(height: 16),
// 상실 정보
_buildLossInfo(context),
// 전투 로그 (있는 경우만 표시)
if (deathInfo.lastCombatEvents.isNotEmpty) ...[
const SizedBox(height: 16),
Divider(color: colorScheme.outlineVariant),
const SizedBox(height: 8),
_buildCombatLog(context),
],
const SizedBox(height: 24),
// 부활 버튼
_buildResurrectButton(context),
],
const SizedBox(height: 24),
// 부활 버튼
_buildResurrectButton(context),
],
),
),
),
),

View File

@@ -369,11 +369,14 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
),
const SizedBox(width: 4),
// HP 숫자
Text(
'$current/$max',
style: const TextStyle(fontSize: 8, color: Colors.orange),
textAlign: TextAlign.right,
// HP 숫자 (Flexible로 오버플로우 방지)
Flexible(
child: Text(
'$current/$max',
style: const TextStyle(fontSize: 8, color: Colors.orange),
textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis,
),
),
],
),