diff --git a/assets/fonts/JetBrainsMono-Regular.ttf b/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/lib/src/core/animation/ascii_animation_data.dart b/lib/src/core/animation/ascii_animation_data.dart index c085b4b..d3bfd4e 100644 --- a/lib/src/core/animation/ascii_animation_data.dart +++ b/lib/src/core/animation/ascii_animation_data.dart @@ -693,6 +693,59 @@ const actCompleteAnimation = AsciiAnimationData( frameIntervalMs: 400, ); +/// 부활 애니메이션 (8줄 x 40자 고정) +/// 어둠에서 빛으로, 캐릭터가 다시 일어남 +const resurrectionAnimation = AsciiAnimationData( + frames: [ + // 프레임 1: 어둠 + ' \n' + ' . . . . . . \n' + ' . . \n' + ' . R . I . P . \n' + ' . ___ . \n' + ' . |___| . \n' + ' . . . . . . \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', + // 프레임 2: 빛이 비침 + ' * \n' + ' . .|. . . . \n' + ' . | . \n' + ' . | . \n' + ' . _O_ . \n' + ' . /___\\ . \n' + ' . . . . . . \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', + // 프레임 3: 일어나는 중 + ' * * * \n' + ' . | . . . \n' + ' . | . \n' + ' . O . \n' + ' . /|\\ . \n' + ' . | | . \n' + ' . . . . . . \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', + // 프레임 4: 서있음 + ' * * * * * \n' + ' \n' + ' \n' + ' O \n' + ' /|\\ \n' + ' / \\ \n' + ' \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', + // 프레임 5: 부활 완료 + ' * RESURRECTED! * \n' + ' \n' + ' \n' + ' \\O/ \n' + ' /|\\ \n' + ' / \\ \n' + ' \n' + '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', + ], + frameIntervalMs: 600, // 5프레임 × 600ms = 3초 +); + /// 타입별 애니메이션 데이터 반환 (기본 전투는 bug) AsciiAnimationData getAnimationData(AsciiAnimationType type) { return switch (type) { @@ -702,5 +755,6 @@ AsciiAnimationData getAnimationData(AsciiAnimationType type) { AsciiAnimationType.levelUp => levelUpAnimation, AsciiAnimationType.questComplete => questCompleteAnimation, AsciiAnimationType.actComplete => actCompleteAnimation, + AsciiAnimationType.resurrection => resurrectionAnimation, }; } diff --git a/lib/src/core/animation/ascii_animation_type.dart b/lib/src/core/animation/ascii_animation_type.dart index a8f6bbc..c046250 100644 --- a/lib/src/core/animation/ascii_animation_type.dart +++ b/lib/src/core/animation/ascii_animation_type.dart @@ -19,6 +19,9 @@ enum AsciiAnimationType { /// Act 완료 (플롯 진행) actComplete, + + /// 부활 (사망 후) + resurrection, } /// TaskType을 AsciiAnimationType으로 변환 diff --git a/lib/src/core/animation/canvas/ascii_canvas_painter.dart b/lib/src/core/animation/canvas/ascii_canvas_painter.dart new file mode 100644 index 0000000..53167e0 --- /dev/null +++ b/lib/src/core/animation/canvas/ascii_canvas_painter.dart @@ -0,0 +1,208 @@ +import 'dart:ui' as ui; + +import 'package:askiineverdie/src/core/animation/canvas/ascii_cell.dart'; +import 'package:askiineverdie/src/core/animation/canvas/ascii_layer.dart'; +import 'package:askiineverdie/src/core/constants/ascii_colors.dart'; +import 'package:flutter/material.dart'; + +/// Paragraph 캐시 키 +class _ParagraphCacheKey { + const _ParagraphCacheKey({ + required this.char, + required this.color, + required this.fontSize, + required this.cellWidth, + }); + + final String char; + final AsciiCellColor color; + final double fontSize; + final double cellWidth; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _ParagraphCacheKey && + char == other.char && + color == other.color && + fontSize == other.fontSize && + cellWidth == other.cellWidth; + + @override + int get hashCode => Object.hash(char, color, fontSize, cellWidth); +} + +/// ASCII Canvas 페인터 (CustomPainter 구현) +/// +/// 레이어 기반으로 ASCII 문자를 Canvas에 그린다. +/// 각 문자는 고정 크기 그리드 셀에 배치된다. +/// Paragraph 캐싱으로 GC 압박 최소화. +class AsciiCanvasPainter extends CustomPainter { + AsciiCanvasPainter({ + required this.layers, + this.gridWidth = 60, + this.gridHeight = 8, + this.backgroundColor = AsciiColors.background, + this.layerVersion = 0, + }); + + /// 렌더링할 레이어 목록 (z-order 정렬 필요) + final List layers; + + /// 그리드 너비 (열 수) + final int gridWidth; + + /// 그리드 높이 (행 수) + final int gridHeight; + + /// 배경색 + final Color backgroundColor; + + /// 레이어 버전 (변경 감지용) + final int layerVersion; + + /// Paragraph 캐시 (문자+색상+크기 조합별) + static final Map<_ParagraphCacheKey, ui.Paragraph> _paragraphCache = {}; + + /// 캐시 크기 제한 + static const int _maxCacheSize = 256; + + /// 마지막 사용된 폰트 크기 (크기 변경 시 캐시 무효화) + static double _lastFontSize = 0; + + @override + void paint(Canvas canvas, Size size) { + // 1. 셀 크기 계산 + final cellWidth = size.width / gridWidth; + final cellHeight = size.height / gridHeight; + final fontSize = cellHeight * 0.85; + + // 폰트 크기 변경 시 캐시 무효화 + if ((fontSize - _lastFontSize).abs() > 0.5) { + _paragraphCache.clear(); + _lastFontSize = fontSize; + } + + // 2. 배경 채우기 + canvas.drawRect( + Rect.fromLTWH(0, 0, size.width, size.height), + Paint()..color = backgroundColor, + ); + + // 3. 레이어별 렌더링 (z-order 순) + for (final layer in layers) { + _renderLayer(canvas, layer, cellWidth, cellHeight, fontSize); + } + } + + /// 단일 레이어 렌더링 + void _renderLayer( + Canvas canvas, + AsciiLayer layer, + double cellWidth, + double cellHeight, + double fontSize, + ) { + for (var row = 0; row < layer.height; row++) { + for (var col = 0; col < layer.width; col++) { + final cell = layer.cells[row][col]; + if (cell.isEmpty) continue; + + // 오프셋 적용된 실제 그리드 위치 + final gridX = col + layer.offsetX; + final gridY = row + layer.offsetY; + + // 그리드 범위 확인 + if (gridX < 0 || gridX >= gridWidth) continue; + if (gridY < 0 || gridY >= gridHeight) continue; + + // 픽셀 좌표 계산 + final x = gridX * cellWidth; + final y = gridY * cellHeight; + + _drawCell(canvas, cell, x, y, cellWidth, cellHeight, fontSize); + } + } + } + + /// 단일 셀 그리기 (캐시 활용) + void _drawCell( + Canvas canvas, + AsciiCell cell, + double x, + double y, + double cellWidth, + double cellHeight, + double fontSize, + ) { + final cacheKey = _ParagraphCacheKey( + char: cell.char, + color: cell.color, + fontSize: fontSize, + cellWidth: cellWidth, + ); + + // 캐시 히트 확인 + var paragraph = _paragraphCache[cacheKey]; + + if (paragraph == null) { + // 캐시 미스: 새 Paragraph 생성 + final color = _getColor(cell.color); + + final paragraphBuilder = ui.ParagraphBuilder( + ui.ParagraphStyle( + fontFamily: 'JetBrainsMono', + fontSize: fontSize, + textAlign: TextAlign.center, + height: 1.0, + ), + ); + + paragraphBuilder.pushStyle(ui.TextStyle(color: color)); + paragraphBuilder.addText(cell.char); + + paragraph = paragraphBuilder.build(); + paragraph.layout(ui.ParagraphConstraints(width: cellWidth)); + + // 캐시 크기 제한 (LRU 대신 단순 클리어) + if (_paragraphCache.length >= _maxCacheSize) { + _paragraphCache.clear(); + } + + _paragraphCache[cacheKey] = paragraph; + } + + // 셀 중앙에 문자 배치 + final offsetX = x + (cellWidth - paragraph.maxIntrinsicWidth) / 2; + final offsetY = y + (cellHeight - paragraph.height) / 2; + + canvas.drawParagraph(paragraph, Offset(offsetX, offsetY)); + } + + /// AsciiCellColor를 Flutter Color로 변환 + Color _getColor(AsciiCellColor cellColor) { + return switch (cellColor) { + AsciiCellColor.background => AsciiColors.background, + AsciiCellColor.object => AsciiColors.object, + AsciiCellColor.positive => AsciiColors.positive, + AsciiCellColor.negative => AsciiColors.negative, + }; + } + + @override + bool shouldRepaint(AsciiCanvasPainter oldDelegate) { + // 레이어 버전으로 빠른 비교 + if (layerVersion != oldDelegate.layerVersion) return true; + + // 동일 참조면 스킵 + if (identical(layers, oldDelegate.layers)) return false; + + // 그리드 설정 변경 확인 + if (gridWidth != oldDelegate.gridWidth || + gridHeight != oldDelegate.gridHeight) { + return true; + } + + return true; + } +} diff --git a/lib/src/core/animation/canvas/ascii_canvas_widget.dart b/lib/src/core/animation/canvas/ascii_canvas_widget.dart new file mode 100644 index 0000000..8c78e06 --- /dev/null +++ b/lib/src/core/animation/canvas/ascii_canvas_widget.dart @@ -0,0 +1,52 @@ +import 'package:askiineverdie/src/core/animation/canvas/ascii_canvas_painter.dart'; +import 'package:askiineverdie/src/core/animation/canvas/ascii_layer.dart'; +import 'package:flutter/material.dart'; + +/// ASCII Canvas 위젯 (RepaintBoundary 포함) +/// +/// AsciiCanvasPainter를 감싸는 위젯. +/// RepaintBoundary로 성능 최적화. +/// willChange를 애니메이션 상태에 따라 동적 설정. +class AsciiCanvasWidget extends StatelessWidget { + const AsciiCanvasWidget({ + super.key, + required this.layers, + this.gridWidth = 60, + this.gridHeight = 8, + this.isAnimating = true, + this.layerVersion = 0, + }); + + /// 렌더링할 레이어 목록 + final List layers; + + /// 그리드 너비 (열 수) + final int gridWidth; + + /// 그리드 높이 (행 수) + final int gridHeight; + + /// 애니메이션 활성 상태 (willChange 최적화용) + final bool isAnimating; + + /// 레이어 버전 (변경 감지용, shouldRepaint 최적화) + final int layerVersion; + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: CustomPaint( + painter: AsciiCanvasPainter( + layers: layers, + gridWidth: gridWidth, + gridHeight: gridHeight, + layerVersion: layerVersion, + ), + size: Size.infinite, + isComplex: true, + // 애니메이션 중일 때만 willChange 활성화 + willChange: isAnimating, + ), + ); + } +} diff --git a/lib/src/core/animation/canvas/ascii_cell.dart b/lib/src/core/animation/canvas/ascii_cell.dart new file mode 100644 index 0000000..38e9e06 --- /dev/null +++ b/lib/src/core/animation/canvas/ascii_cell.dart @@ -0,0 +1,64 @@ +/// ASCII 셀 색상 (4색 팔레트) +enum AsciiCellColor { + /// 배경색 (검정) + background, + + /// 오브젝트 (흰색) - 캐릭터, 몬스터, 지형 + object, + + /// 포지티브 이펙트 (시안) - !, +, =, >, < + positive, + + /// 네거티브 이펙트 (마젠타) - *, ~ + negative, +} + +/// 단일 ASCII 셀 데이터 +class AsciiCell { + const AsciiCell({ + required this.char, + this.color = AsciiCellColor.object, + }); + + /// 표시할 문자 (단일 문자) + final String char; + + /// 셀 색상 타입 + final AsciiCellColor color; + + /// 빈 셀 (투명) + static const empty = AsciiCell(char: ' '); + + /// 문자가 공백인지 확인 (투명 처리용) + bool get isEmpty => char == ' ' || char.isEmpty; + + /// 문자에서 색상 자동 결정 + static AsciiCellColor colorFromChar(String char) { + // 포지티브 이펙트 문자 (시안) + if ('!+=><'.contains(char)) return AsciiCellColor.positive; + // 네거티브 이펙트 문자 (마젠타) + if ('*~'.contains(char)) return AsciiCellColor.negative; + // 기본 오브젝트 (흰색) + return AsciiCellColor.object; + } + + /// 문자열에서 AsciiCell 생성 (자동 색상) + factory AsciiCell.fromChar(String char) { + if (char.isEmpty || char == ' ') return empty; + return AsciiCell( + char: char, + color: colorFromChar(char), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AsciiCell && + runtimeType == other.runtimeType && + char == other.char && + color == other.color; + + @override + int get hashCode => char.hashCode ^ color.hashCode; +} diff --git a/lib/src/core/animation/canvas/ascii_layer.dart b/lib/src/core/animation/canvas/ascii_layer.dart new file mode 100644 index 0000000..aca71c3 --- /dev/null +++ b/lib/src/core/animation/canvas/ascii_layer.dart @@ -0,0 +1,70 @@ +import 'package:askiineverdie/src/core/animation/canvas/ascii_cell.dart'; + +/// ASCII 레이어 데이터 구조 (Canvas 렌더러용) +/// +/// 2D 셀 배열과 위치/깊이 정보를 담는다. +class AsciiLayer { + const AsciiLayer({ + required this.cells, + this.zIndex = 0, + this.offsetX = 0, + this.offsetY = 0, + }); + + /// 2D 셀 배열 [row][column] + final List> cells; + + /// Z 순서 (낮을수록 뒤쪽) + final int zIndex; + + /// X 오프셋 (스크롤/이동용) + final int offsetX; + + /// Y 오프셋 + final int offsetY; + + /// 레이어 높이 (줄 수) + int get height => cells.length; + + /// 레이어 너비 (열 수) + int get width => cells.isEmpty ? 0 : cells.first.length; + + /// 특정 위치의 셀 반환 (범위 밖이면 empty) + AsciiCell getCell(int row, int col) { + if (row < 0 || row >= height) return AsciiCell.empty; + if (col < 0 || col >= cells[row].length) return AsciiCell.empty; + return cells[row][col]; + } + + /// 빈 레이어 생성 + factory AsciiLayer.empty({ + int width = 60, + int height = 8, + int zIndex = 0, + }) { + final cells = List.generate( + height, + (_) => List.filled(width, AsciiCell.empty), + ); + return AsciiLayer(cells: cells, zIndex: zIndex); + } + + /// 문자열 리스트에서 레이어 생성 (자동 색상) + factory AsciiLayer.fromLines( + List lines, { + int zIndex = 0, + int offsetX = 0, + int offsetY = 0, + }) { + final cells = lines.map((line) { + return line.split('').map(AsciiCell.fromChar).toList(); + }).toList(); + + return AsciiLayer( + cells: cells, + zIndex: zIndex, + offsetX: offsetX, + offsetY: offsetY, + ); + } +} diff --git a/lib/src/core/animation/canvas/canvas_battle_composer.dart b/lib/src/core/animation/canvas/canvas_battle_composer.dart new file mode 100644 index 0000000..93d5eee --- /dev/null +++ b/lib/src/core/animation/canvas/canvas_battle_composer.dart @@ -0,0 +1,1014 @@ +import 'package:askiineverdie/src/core/animation/ascii_animation_data.dart'; +import 'package:askiineverdie/src/core/animation/background_data.dart'; +import 'package:askiineverdie/src/core/animation/background_layer.dart'; +import 'package:askiineverdie/src/core/animation/canvas/ascii_cell.dart'; +import 'package:askiineverdie/src/core/animation/canvas/ascii_layer.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/animation/weapon_effects.dart'; + +/// Canvas용 전투 프레임 합성기 +/// +/// 기존 BattleComposer의 로직을 레이어 기반으로 변환. +/// 출력: `List` (z-order 정렬됨) +class CanvasBattleComposer { + const CanvasBattleComposer({ + required this.weaponCategory, + required this.hasShield, + required this.monsterCategory, + required this.monsterSize, + }); + + final WeaponCategory weaponCategory; + final bool hasShield; + final MonsterCategory monsterCategory; + final MonsterSize monsterSize; + + /// 프레임 상수 + static const int frameWidth = 60; + static const int frameHeight = 8; + static const int monsterWidth = 18; + + /// 레이어 기반 프레임 생성 + List composeLayers( + BattlePhase phase, + int subFrame, + String? monsterBaseName, + EnvironmentType environment, + int globalTick, + ) { + final layers = [ + _createBackgroundLayer(environment, globalTick), + _createCharacterLayer(phase, subFrame), + _createMonsterLayer(phase, subFrame), + ]; + + // 이펙트 레이어 (공격/히트 페이즈에서만) + if (phase == BattlePhase.attack || phase == BattlePhase.hit) { + final effectLayer = _createEffectLayer(phase, subFrame); + if (effectLayer != null) { + layers.add(effectLayer); + } + } + + // z-order 정렬 + layers.sort((a, b) => a.zIndex.compareTo(b.zIndex)); + + return layers; + } + + /// 배경 레이어 생성 (z=0) + AsciiLayer _createBackgroundLayer( + EnvironmentType environment, + int globalTick, + ) { + final cells = List.generate( + frameHeight, + (_) => List.filled(frameWidth, AsciiCell.empty), + ); + + final bgLayers = getBackgroundLayers(environment); + for (final layer in bgLayers) { + // 스크롤 오프셋 계산 + final offset = (globalTick * layer.scrollSpeed).toInt(); + + for (var i = 0; i < layer.lines.length; i++) { + final y = layer.yStart + i; + if (y >= frameHeight) break; + + final pattern = layer.lines[i]; + if (pattern.isEmpty) continue; + + for (var x = 0; x < frameWidth; x++) { + final patternIdx = (x + offset) % pattern.length; + final char = pattern[patternIdx]; + if (char != ' ') { + cells[y][x] = AsciiCell.fromChar(char); + } + } + } + } + + return AsciiLayer(cells: cells, zIndex: 0); + } + + /// 캐릭터 레이어 생성 (z=1) + AsciiLayer _createCharacterLayer(BattlePhase phase, int subFrame) { + var charFrame = getCharacterFrame(phase, subFrame); + if (hasShield) { + charFrame = charFrame.withShield(); + } + + // 페이즈별 X 위치 (idle: 20%, attack: 30%) + final charX = switch (phase) { + BattlePhase.idle => 12, // 20% + BattlePhase.prepare => 15, // 전환중 + BattlePhase.attack => 18, // 30% + BattlePhase.hit => 18, // 30% + BattlePhase.recover => 15, // 전환중 + }; + + final cells = _spriteToCells(charFrame.lines); + // 바닥 레이어(Y=7) 위에 서있도록 + final charY = frameHeight - cells.length - 1; + + return AsciiLayer( + cells: cells, + zIndex: 1, + offsetX: charX, + offsetY: charY, + ); + } + + /// 몬스터 레이어 생성 (z=1) + AsciiLayer _createMonsterLayer(BattlePhase phase, int subFrame) { + final monsterFrames = _getAnimatedMonsterFrames( + monsterCategory, + monsterSize, + phase, + ); + final monsterFrame = monsterFrames[subFrame % monsterFrames.length]; + + // 몬스터 스프라이트를 오른쪽 정렬하여 셀로 변환 + final cells = _spriteToRightAlignedCells(monsterFrame, monsterWidth); + + // 페이즈별 X 위치 (idle: 80%, attack: 70%) + // 몬스터 오른쪽 끝 기준: idle=48, attack=42 + final monsterRightEdge = switch (phase) { + BattlePhase.idle => 48, // 80% + BattlePhase.prepare => 45, // 전환중 + BattlePhase.attack => 42, // 70% + BattlePhase.hit => 42, // 70% + BattlePhase.recover => 45, // 전환중 + }; + final monsterX = monsterRightEdge - monsterWidth; + + // 바닥 레이어(Y=7) 위에 서있도록 + final monsterY = frameHeight - cells.length - 1; + + return AsciiLayer( + cells: cells, + zIndex: 1, + offsetX: monsterX, + offsetY: monsterY, + ); + } + + /// 이펙트 레이어 생성 (z=2) + AsciiLayer? _createEffectLayer(BattlePhase phase, int subFrame) { + final effect = getWeaponEffect(weaponCategory); + final effectLines = _getEffectLines(effect, phase, subFrame); + + if (effectLines.isEmpty) return null; + + final cells = _spriteToCells(effectLines); + + // 이펙트 위치: 캐릭터 오른쪽 (30% 위치 + 캐릭터 너비) + final charX = switch (phase) { + BattlePhase.attack => 18, // 30% + BattlePhase.hit => 18, // 30% + _ => 12, + }; + final effectX = charX + 6; // 캐릭터 너비만큼 오른쪽 + // 캐릭터 3줄 기준, 머리 위치 + final effectY = frameHeight - 3 - 1; + + return AsciiLayer( + cells: cells, + zIndex: 2, + offsetX: effectX, + offsetY: effectY, + ); + } + + /// 문자열 스프라이트를 AsciiCell 2D 배열로 변환 + List> _spriteToCells(List lines) { + return lines.map((line) { + return line.split('').map(AsciiCell.fromChar).toList(); + }).toList(); + } + + /// 문자열 스프라이트를 오른쪽 정렬된 AsciiCell 2D 배열로 변환 + List> _spriteToRightAlignedCells( + List lines, + int width, + ) { + // 전체 스프라이트에서 최대 너비 계산 + int maxWidth = 0; + for (final line in lines) { + final trimmed = line.trimRight().length; + if (trimmed > maxWidth) maxWidth = trimmed; + } + + // 왼쪽 패딩 계산 + final leftPadding = width - maxWidth; + + return lines.map((line) { + final trimmed = line.trimRight(); + final cells = []; + + // 왼쪽 패딩 + for (var i = 0; i < leftPadding; i++) { + cells.add(AsciiCell.empty); + } + + // 실제 문자 + for (var i = 0; i < trimmed.length; i++) { + cells.add(AsciiCell.fromChar(trimmed[i])); + } + + // 오른쪽 채우기 (필요시) + while (cells.length < width) { + cells.add(AsciiCell.empty); + } + + return cells; + }).toList(); + } + + /// 멀티라인 이펙트 프레임 반환 + List _getEffectLines( + WeaponEffect effect, + BattlePhase phase, + int subFrame, + ) { + final frames = switch (phase) { + BattlePhase.idle => >[], + BattlePhase.prepare => effect.prepareFrames, + BattlePhase.attack => effect.attackFrames, + BattlePhase.hit => effect.hitFrames, + BattlePhase.recover => >[], + }; + if (frames.isEmpty) return []; + return frames[subFrame % frames.length]; + } +} + +// ============================================================================ +// 몬스터 애니메이션 프레임 (기존 BattleComposer에서 이식) +// ============================================================================ + +/// 몬스터 애니메이션 프레임 반환 (페이즈별 다른 동작) +List> _getAnimatedMonsterFrames( + MonsterCategory category, + MonsterSize size, + BattlePhase phase, +) { + // 피격 상태 + if (phase == BattlePhase.hit) { + return _getMonsterHitFrames(category, size); + } + // 경계 상태 (prepare, attack) + if (phase == BattlePhase.prepare || phase == BattlePhase.attack) { + return _getMonsterAlertFrames(category, size); + } + // 일반 상태 (idle, recover) + return _getMonsterIdleFrames(category, size); +} + +List> _getMonsterIdleFrames( + MonsterCategory category, + MonsterSize size, +) { + return switch (size) { + MonsterSize.tiny => _tinyIdleFrames(category), + MonsterSize.small => _smallIdleFrames(category), + MonsterSize.medium => _mediumIdleFrames(category), + // large, huge, giant, titanic 모두 8줄 (large 프레임 사용) + MonsterSize.large || + MonsterSize.huge || + MonsterSize.giant || + MonsterSize.titanic => + _largeIdleFrames(category), + }; +} + +List> _getMonsterHitFrames( + MonsterCategory category, + MonsterSize size, +) { + return switch (size) { + MonsterSize.tiny => _tinyHitFrames(category), + MonsterSize.small => _smallHitFrames(category), + MonsterSize.medium => _mediumHitFrames(category), + MonsterSize.large || + MonsterSize.huge || + MonsterSize.giant || + MonsterSize.titanic => + _largeHitFrames(category), + }; +} + +List> _getMonsterAlertFrames( + MonsterCategory category, + MonsterSize size, +) { + return switch (size) { + MonsterSize.tiny => _tinyAlertFrames(category), + MonsterSize.small => _smallAlertFrames(category), + MonsterSize.medium => _mediumAlertFrames(category), + MonsterSize.large || + MonsterSize.huge || + MonsterSize.giant || + MonsterSize.titanic => + _largeAlertFrames(category), + }; +} + +// ============================================================================ +// Tiny 몬스터 (2줄) +// ============================================================================ + +List> _tinyIdleFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.bug => [ + [r'(o.o)', r' |_|'], + [r'(o o)', r' |_|'], + ], + MonsterCategory.malware => [ + [r'', r' \_/'], + [r'', r' /_\'], + ], + MonsterCategory.network => [ + [r' @', r'/|\'], + [r' @', r'\|/'], + ], + MonsterCategory.system => [ + [r'[x_x]', r' /_\'], + [r'[X_X]', r' \_/'], + ], + MonsterCategory.crypto => [ + [r'$(.)~', r' /_\'], + [r'$(.)`', r' \_/'], + ], + MonsterCategory.ai => [ + [r'{o}', r'/_\'], + [r'{O}', r'\_/'], + ], + MonsterCategory.boss => [ + [r'(|o|)', r' V V'], + [r'(|O|)', r' v v'], + ], + }; +} + +List> _tinyHitFrames(MonsterCategory category) { + return [ + [r'(*!*)', r' X_X'], + [r'(!*!)', r' x_x'], + ]; +} + +List> _tinyAlertFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.bug => [ + [r'(O!O)', r' |!|'], + [r'(!O!)', r' |!|'], + ], + MonsterCategory.malware => [ + [r'', r' \!/'], + [r'', r' /!\'], + ], + MonsterCategory.network => [ + [r' @!', r'/|\'], + [r' !@', r'\|/'], + ], + MonsterCategory.system => [ + [r'[!_!]', r' /!\'], + [r'[!_!]', r' \!/'], + ], + MonsterCategory.crypto => [ + [r'$(!)~', r' /!\'], + [r'$(!)`', r' \!/'], + ], + MonsterCategory.ai => [ + [r'{!}', r'/!\'], + [r'{!}', r'\!/'], + ], + MonsterCategory.boss => [ + [r'(|!|)', r' V!V'], + [r'(|!|)', r' v!v'], + ], + }; +} + +// ============================================================================ +// Small 몬스터 (4줄) +// ============================================================================ + +List> _smallIdleFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.bug => [ + [r' /\_/\', r'( O.O )', r' > ^ <', r' /| |\'], + [r' /\_/\', r'( O O )', r' > v <', r' \| |/'], + ], + MonsterCategory.malware => [ + [r' /\/\', r' (O O)', r' / \', r' \/ \/'], + [r' \/\/\', r' (O O)', r' \ /', r' /\ /\'], + ], + MonsterCategory.network => [ + [r' O', r' /|\', r' / \', r' _| |_'], + [r' O', r' \|/', r' | |', r' _/ \_'], + ], + MonsterCategory.system => [ + [r' _+_', r' (x_x)', r' /|\', r' _/ \_'], + [r' _+_', r' (X_X)', r' \|/', r' _| |_'], + ], + MonsterCategory.crypto => [ + [r' __', r' <(oo)~', r' / \', r' <_ _>'], + [r' __', r' (oo)>', r' \ /', r' <_ _>'], + ], + MonsterCategory.ai => [ + [r' ___', r' ( )', r' ( )', r' \_/'], + [r' _', r' / \', r' { }', r' \_/'], + ], + MonsterCategory.boss => [ + [r' ^w^', r' (|o|)', r' /|\', r' V V'], + [r' ^W^', r' (|O|)', r' \|/', r' v v'], + ], + }; +} + +List> _smallHitFrames(MonsterCategory category) { + return [ + [r' *!*', r' (>_<)', r' \X/', r' _/_\_'], + [r' !*!', r' (@_@)', r' /X\', r' _\_/_'], + ]; +} + +List> _smallAlertFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.bug => [ + [r' /\_/\', r'( O!O )', r' > ! <', r' /| |\'], + [r' /\_/\', r'( !O! )', r' > ! <', r' \| |/'], + ], + MonsterCategory.malware => [ + [r' /\/\', r' (! !)', r' / \', r' \/ \/'], + [r' \/\/\', r' (! !)', r' \ /', r' /\ /\'], + ], + MonsterCategory.network => [ + [r' O!', r' /|\', r' / \', r' _| |_'], + [r' !O', r' \|/', r' | |', r' _/ \_'], + ], + MonsterCategory.system => [ + [r' _!_', r' (!_!)', r' /|\', r' _/ \_'], + [r' _!_', r' (!_!)', r' \|/', r' _| |_'], + ], + MonsterCategory.crypto => [ + [r' __', r' <(!!)~', r' / \', r' <_ _>'], + [r' __', r' (!!)>', r' \ /', r' <_ _>'], + ], + MonsterCategory.ai => [ + [r' ___', r' ( ! )', r' ( ! )', r' \_/'], + [r' _', r' /!\', r' { ! }', r' \_/'], + ], + MonsterCategory.boss => [ + [r' ^!^', r' (|!|)', r' /|\', r' V V'], + [r' ^!^', r' (|!|)', r' \|/', r' v v'], + ], + }; +} + +// ============================================================================ +// Medium 몬스터 (6줄) +// ============================================================================ + +List> _mediumIdleFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.bug => [ + [ + r' /\_/\', + r' ( O.O )', + r' > ^ <', + r' /| |\', + r' | | | |', + r'_|_| |_|_' + ], + [ + r' /\_/\', + r' ( O O )', + r' > v <', + r' \| |/', + r' | | | |', + r'_|_| |_|_' + ], + ], + MonsterCategory.malware => [ + [ + r' /\/\', + r' /O O\', + r' \ /', + r' / \', + r' \/ \/', + r' _/ \_' + ], + [ + r' \/\/\', + r' \O O/', + r' / \', + r' \ /', + r' /\ /\', + r' _\ /_' + ], + ], + MonsterCategory.network => [ + [r' O', r' /|\', r' / \', r' | |', r' | |', r' _| |_'], + [r' O', r' \|/', r' | |', r' | |', r' | |', r' _/ \_'], + ], + MonsterCategory.system => [ + [ + r' _+_', + r' (X_X)', + r' /|\', + r' / | \', + r' | | |', + r'_/ | \_' + ], + [ + r' _x_', + r' (x_x)', + r' \|/', + r' \ | /', + r' | | |', + r'_\ | /_' + ], + ], + MonsterCategory.crypto => [ + [ + r' __', + r' <(OO)~', + r' / \', + r' / \', + r' | |', + r'<__ __>' + ], + [ + r' __', + r' (OO)>', + r' \ /', + r' \ /', + r' | |', + r'<__ __>' + ], + ], + MonsterCategory.ai => [ + [r' ____', r' / \', r' ( )', r' ( )', r' \ /', r' \__/'], + [r' __', r' / \', r' / \', r' { }', r' \ /', r' \__/'], + ], + MonsterCategory.boss => [ + [r' ^W^', r' (|O|)', r' /|\', r' / | \', r' V V', r' _/ \_'], + [r' ^w^', r' (|o|)', r' \|/', r' \ | /', r' v v', r' _\ /_'], + ], + }; +} + +List> _mediumHitFrames(MonsterCategory category) { + return [ + [r' *!*', r' (>.<)', r' \X/', r' / \', r' | |', r'_/_ \_\'], + [r' !*!', r' (@_@)', r' /X\', r' \ /', r' | |', r'_\_ /_/'], + ]; +} + +List> _mediumAlertFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.bug => [ + [ + r' /\_/\', + r' ( O!O )', + r' > ! <', + r' /| |\', + r' | | | |', + r'_|_| |_|_' + ], + [ + r' /\_/\', + r' ( !O! )', + r' > ! <', + r' \| |/', + r' | | | |', + r'_|_| |_|_' + ], + ], + MonsterCategory.malware => [ + [ + r' /\/\', + r' /! !\', + r' \ /', + r' / \', + r' \/ \/', + r' _/ \_' + ], + [ + r' \/\/\', + r' \! !/', + r' / \', + r' \ /', + r' /\ /\', + r' _\ /_' + ], + ], + MonsterCategory.network => [ + [r' O!', r' /|\', r' / \', r' | |', r' | |', r' _| |_'], + [r' !O', r' \|/', r' | |', r' | |', r' | |', r' _/ \_'], + ], + MonsterCategory.system => [ + [ + r' _!_', + r' (!_!)', + r' /|\', + r' / | \', + r' | | |', + r'_/ | \_' + ], + [ + r' _!_', + r' (!_!)', + r' \|/', + r' \ | /', + r' | | |', + r'_\ | /_' + ], + ], + MonsterCategory.crypto => [ + [ + r' __', + r' <(!!)~', + r' / \', + r' / \', + r' | |', + r'<__ __>' + ], + [ + r' __', + r' (!!)>', + r' \ /', + r' \ /', + r' | |', + r'<__ __>' + ], + ], + MonsterCategory.ai => [ + [ + r' ____', + r' / ! \', + r' ( ! )', + r' ( ! )', + r' \ /', + r' \__/' + ], + [r' __', r' / !\', r' / ! \', r' { ! }', r' \ /', r' \__/'], + ], + MonsterCategory.boss => [ + [r' ^!^', r' (|!|)', r' /|\', r' / | \', r' V V', r' _/ \_'], + [r' ^!^', r' (|!|)', r' \|/', r' \ | /', r' v v', r' _\ /_'], + ], + }; +} + +// ============================================================================ +// Large 몬스터 (8줄) +// ============================================================================ + +List> _largeIdleFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.bug => [ + [ + r' /\__/\', + r' ( O O )', + r' > ^^ <', + r' /| |\', + r' | | | |', + r' | | | |', + r'_| | | |_', + r'|__|____|__|' + ], + [ + r' /\__/\', + r' ( O O )', + r' > vv <', + r' \| |/', + r' | | | |', + r' | | | |', + r'_| | | |_', + r'|__|____|__|' + ], + ], + MonsterCategory.malware => [ + [ + r' /\/\', + r' /O O\', + r' \ /', + r' / \', + r' / \', + r' \/ \/', + r' _/ \_', + r'/__ __\\' + ], + [ + r' \/\/\', + r' \O O/', + r' / \', + r' \ /', + r' \ /', + r' /\ /\', + r' _\ /_', + r'\__ __/' + ], + ], + MonsterCategory.network => [ + [ + r' O', + r' /|\', + r' / \', + r' | |', + r' | |', + r' | |', + r' _| |_', + r'|__ __|' + ], + [ + r' O', + r' \|/', + r' | |', + r' | |', + r' | |', + r' | |', + r' _/ \_', + r'/__ __\\' + ], + ], + MonsterCategory.system => [ + [ + r' _+_', + r' (X_X)', + r' /|\', + r' / | \', + r' | | |', + r' | | |', + r' _/ | \_', + r'|____|____|' + ], + [ + r' _x_', + r' (x_x)', + r' \|/', + r' \ | /', + r' | | |', + r' | | |', + r' _\ | /_', + r'|____|____|' + ], + ], + MonsterCategory.crypto => [ + [ + r' ___', + r' <(O O)~', + r' / \', + r' / \', + r' | |', + r' | |', + r' <__ __>', + r'|___ ___|' + ], + [ + r' ___', + r' (O O)>', + r' \ /', + r' \ /', + r' | |', + r' | |', + r' <__ __>', + r'|___ ___|' + ], + ], + MonsterCategory.ai => [ + [ + r' _____', + r' / \', + r' ( )', + r' ( )', + r' ( )', + r' \ /', + r' \ /', + r' \_/' + ], + [ + r' ___', + r' / \', + r' / \', + r' { }', + r' { }', + r' \ /', + r' \ /', + r' \_/' + ], + ], + MonsterCategory.boss => [ + [ + r' ^W^', + r' /|O|\', + r' /|\', + r' / | \', + r' | | |', + r' V | V', + r' _/ | \_', + r'|_____|_____|' + ], + [ + r' ^w^', + r' \|o|/', + r' \|/', + r' \ | /', + r' | | |', + r' v | v', + r' _\ | /_', + r'|_____|_____|' + ], + ], + }; +} + +List> _largeHitFrames(MonsterCategory category) { + return [ + [ + r' *!*', + r' (>.<)', + r' \X/', + r' / | \', + r' | | |', + r' X | X', + r' _/ | \_', + r'|_____|_____|' + ], + [ + r' !*!', + r' (@_@)', + r' /X\', + r' \ | /', + r' | | |', + r' x | x', + r' _\ | /_', + r'|_____|_____|' + ], + ]; +} + +List> _largeAlertFrames(MonsterCategory category) { + return switch (category) { + MonsterCategory.bug => [ + [ + r' /\__/\', + r' ( O!O )', + r' > !! <', + r' /| |\', + r' | | | |', + r' | | | |', + r'_| | | |_', + r'|__|____|__|' + ], + [ + r' /\__/\', + r' ( !O! )', + r' > !! <', + r' \| |/', + r' | | | |', + r' | | | |', + r'_| | | |_', + r'|__|____|__|' + ], + ], + MonsterCategory.malware => [ + [ + r' /\/\', + r' /! !\', + r' \ /', + r' / \', + r' / \', + r' \/ \/', + r' _/ \_', + r'/__ __\\' + ], + [ + r' \/\/\', + r' \! !/', + r' / \', + r' \ /', + r' \ /', + r' /\ /\', + r' _\ /_', + r'\__ __/' + ], + ], + MonsterCategory.network => [ + [ + r' O!', + r' /|\', + r' / \', + r' | |', + r' | |', + r' | |', + r' _| |_', + r'|__ __|' + ], + [ + r' !O', + r' \|/', + r' | |', + r' | |', + r' | |', + r' | |', + r' _/ \_', + r'/__ __\\' + ], + ], + MonsterCategory.system => [ + [ + r' _!_', + r' (!_!)', + r' /|\', + r' / | \', + r' | | |', + r' | | |', + r' _/ | \_', + r'|____|____|' + ], + [ + r' _!_', + r' (!_!)', + r' \|/', + r' \ | /', + r' | | |', + r' | | |', + r' _\ | /_', + r'|____|____|' + ], + ], + MonsterCategory.crypto => [ + [ + r' ___', + r' <(! !)~', + r' / \', + r' / \', + r' | |', + r' | |', + r' <__ __>', + r'|___ ___|' + ], + [ + r' ___', + r' (! !)>', + r' \ /', + r' \ /', + r' | |', + r' | |', + r' <__ __>', + r'|___ ___|' + ], + ], + MonsterCategory.ai => [ + [ + r' _____', + r' / ! \', + r' ( ! )', + r' ( ! )', + r' ( ! )', + r' \ /', + r' \ /', + r' \_/' + ], + [ + r' ___', + r' / ! \', + r' / ! \', + r' { ! }', + r' { ! }', + r' \ /', + r' \ /', + r' \_/' + ], + ], + MonsterCategory.boss => [ + [ + r' ^!^', + r' /|!|\', + r' /|\', + r' / | \', + r' | | |', + r' V | V', + r' _/ | \_', + r'|_____|_____|' + ], + [ + r' ^!^', + r' \|!|/', + r' \|/', + r' \ | /', + r' | | |', + r' v | v', + r' _\ | /_', + r'|_____|_____|' + ], + ], + }; +} diff --git a/lib/src/core/animation/canvas/canvas_special_composer.dart b/lib/src/core/animation/canvas/canvas_special_composer.dart new file mode 100644 index 0000000..afdd1b8 --- /dev/null +++ b/lib/src/core/animation/canvas/canvas_special_composer.dart @@ -0,0 +1,269 @@ +import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart'; +import 'package:askiineverdie/src/core/animation/canvas/ascii_cell.dart'; +import 'package:askiineverdie/src/core/animation/canvas/ascii_layer.dart'; + +/// Canvas용 특수 이벤트 애니메이션 합성기 +/// +/// 레벨업, 퀘스트 완료, Act 완료, 부활 등 +class CanvasSpecialComposer { + const CanvasSpecialComposer(); + + /// 프레임 상수 + static const int frameWidth = 60; + static const int frameHeight = 8; + + /// 레이어 기반 프레임 생성 + List composeLayers( + AsciiAnimationType type, + int frameIndex, + int globalTick, + ) { + return switch (type) { + AsciiAnimationType.levelUp => _composeLevelUp(frameIndex, globalTick), + AsciiAnimationType.questComplete => + _composeQuestComplete(frameIndex, globalTick), + AsciiAnimationType.actComplete => + _composeActComplete(frameIndex, globalTick), + AsciiAnimationType.resurrection => + _composeResurrection(frameIndex, globalTick), + _ => [AsciiLayer.empty()], + }; + } + + /// 레벨업 애니메이션 + List _composeLevelUp(int frameIndex, int globalTick) { + final layers = [ + _createEffectBackground(globalTick, '*'), + _createCenteredSprite(_levelUpFrames[frameIndex % _levelUpFrames.length]), + ]; + return layers; + } + + /// 퀘스트 완료 애니메이션 + List _composeQuestComplete(int frameIndex, int globalTick) { + final layers = [ + _createEffectBackground(globalTick, '+'), + _createCenteredSprite( + _questCompleteFrames[frameIndex % _questCompleteFrames.length]), + ]; + return layers; + } + + /// Act 완료 애니메이션 + List _composeActComplete(int frameIndex, int globalTick) { + final layers = [ + _createEffectBackground(globalTick, '~'), + _createCenteredSprite( + _actCompleteFrames[frameIndex % _actCompleteFrames.length]), + ]; + return layers; + } + + /// 부활 애니메이션 + List _composeResurrection(int frameIndex, int globalTick) { + final layers = [ + _createEffectBackground(globalTick, '.'), + _createCenteredSprite( + _resurrectionFrames[frameIndex % _resurrectionFrames.length]), + ]; + return layers; + } + + /// 이펙트 배경 레이어 생성 (z=0) + AsciiLayer _createEffectBackground(int globalTick, String effectChar) { + final cells = List.generate( + frameHeight, + (_) => List.filled(frameWidth, AsciiCell.empty), + ); + + // 반짝이는 이펙트 + for (var y = 0; y < frameHeight; y++) { + for (var x = 0; x < frameWidth; x++) { + final offset = (x + y + globalTick) % 8; + if (offset == 0) { + cells[y][x] = AsciiCell.fromChar(effectChar); + } + } + } + + return AsciiLayer(cells: cells, zIndex: 0); + } + + /// 중앙 정렬 스프라이트 레이어 생성 (z=1) + AsciiLayer _createCenteredSprite(List lines) { + final cells = _spriteToCells(lines); + + // 중앙 정렬 + final spriteWidth = lines.isEmpty ? 0 : lines[0].length; + final offsetX = (frameWidth - spriteWidth) ~/ 2; + final offsetY = (frameHeight - cells.length) ~/ 2; + + return AsciiLayer( + cells: cells, + zIndex: 1, + offsetX: offsetX, + offsetY: offsetY, + ); + } + + /// 문자열 스프라이트를 AsciiCell 2D 배열로 변환 + List> _spriteToCells(List lines) { + return lines.map((line) { + return line.split('').map(AsciiCell.fromChar).toList(); + }).toList(); + } +} + +// ============================================================================ +// 레벨업 프레임 (5프레임) +// ============================================================================ + +const _levelUpFrames = [ + [ + r' * ', + r' \|/ ', + r' o ', + r' /|\ ', + r' / \ ', + ], + [ + r' * * ', + r' \|/ ', + r' O ', + r' ', + r' / \ ', + ], + [ + r' * * * ', + r' \|/ ', + r' O ', + r' <\|/> ', + r' / \ ', + ], + [ + r' * * * * ', + r' LEVEL ', + r' UP! ', + r' \O/ ', + r' / \ ', + ], + [ + r'* * * * *', + r' LEVEL ', + r' UP! ', + r' \O/ ', + r' | | ', + ], +]; + +// ============================================================================ +// 퀘스트 완료 프레임 (4프레임) +// ============================================================================ + +const _questCompleteFrames = [ + [ + r' [?] ', + r' | ', + r' o ', + r' /|\ ', + r' / \ ', + ], + [ + r' [???] ', + r' | ', + r' o! ', + r' /|\ ', + r' / \ ', + ], + [ + r' [DONE] ', + r' ! ', + r' \o/ ', + r' | ', + r' / \ ', + ], + [ + r' +[DONE]+', + r' \!/ ', + r' \o/ ', + r' | ', + r' / \ ', + ], +]; + +// ============================================================================ +// Act 완료 프레임 (4프레임) +// ============================================================================ + +const _actCompleteFrames = [ + [ + r'=========', + r' ACT ', + r' CLEAR ', + r' o ', + r' /|\ ', + ], + [ + r'~~~~~~~~~', + r' ACT ', + r' CLEAR! ', + r' \o/ ', + r' | ', + ], + [ + r'*~*~*~*~*', + r' ACT ', + r' CLEAR!! ', + r' \O/ ', + r' / \ ', + ], + [ + r'*********', + r' ACT ', + r' CLEAR!! ', + r' \O/ ', + r' | | ', + ], +]; + +// ============================================================================ +// 부활 프레임 (5프레임) +// ============================================================================ + +const _resurrectionFrames = [ + // 프레임 1: R.I.P 묘비 + [ + r' ___ ', + r' |RIP| ', + r' | | ', + r'__|___|__', + ], + // 프레임 2: 빛 내림 + [ + r' \|/ ', + r' -|R|- ', + r' | | ', + r'__|___|__', + ], + // 프레임 3: 일어남 + [ + r' \o/ ', + r' --|-- ', + r' | | ', + r'__|___|__', + ], + // 프레임 4: 서있음 + [ + r' o ', + r' /|\ ', + r' / \ ', + r'_________', + ], + // 프레임 5: 부활 완료 + [ + r' REVIVED ', + r' \o/ ', + r' | ', + r'___/ \___', + ], +]; diff --git a/lib/src/core/animation/canvas/canvas_town_composer.dart b/lib/src/core/animation/canvas/canvas_town_composer.dart new file mode 100644 index 0000000..7ffb22b --- /dev/null +++ b/lib/src/core/animation/canvas/canvas_town_composer.dart @@ -0,0 +1,130 @@ +import 'package:askiineverdie/src/core/animation/canvas/ascii_cell.dart'; +import 'package:askiineverdie/src/core/animation/canvas/ascii_layer.dart'; + +/// Canvas용 마을/상점 애니메이션 합성기 +/// +/// 마을 배경 + 상점 건물 + 캐릭터 +class CanvasTownComposer { + const CanvasTownComposer(); + + /// 프레임 상수 + static const int frameWidth = 60; + static const int frameHeight = 8; + + /// 레이어 기반 프레임 생성 + List composeLayers(int globalTick) { + return [ + _createBackgroundLayer(), + _createShopLayer(), + _createCharacterLayer(globalTick), + ]; + } + + /// 마을 배경 레이어 생성 (z=0) + AsciiLayer _createBackgroundLayer() { + final cells = List.generate( + frameHeight, + (_) => List.filled(frameWidth, AsciiCell.empty), + ); + + // 하늘 (상단 2줄) + for (var x = 0; x < frameWidth; x++) { + // 별/구름 패턴 + if (x % 12 == 3) { + cells[0][x] = AsciiCell.fromChar('*'); + } + if (x % 8 == 5) { + cells[1][x] = AsciiCell.fromChar('~'); + } + } + + // 바닥 (하단 1줄) + for (var x = 0; x < frameWidth; x++) { + cells[7][x] = AsciiCell.fromChar('='); + } + + return AsciiLayer(cells: cells, zIndex: 0); + } + + /// 상점 건물 레이어 생성 (z=1) + AsciiLayer _createShopLayer() { + const shopLines = [ + r' ___________ ', + r' / SHOP \ ', + r' | _______ | ', + r' | | | | ', + r' | | $$ | || ', + r'___|_|_______|__||____', + ]; + + final cells = _spriteToCells(shopLines); + + // 상점 위치 (오른쪽) + const shopX = 32; + final shopY = frameHeight - cells.length - 1; + + return AsciiLayer( + cells: cells, + zIndex: 1, + offsetX: shopX, + offsetY: shopY, + ); + } + + /// 캐릭터 레이어 생성 (z=2) + AsciiLayer _createCharacterLayer(int globalTick) { + final frameIndex = globalTick % _shopIdleFrames.length; + final charFrame = _shopIdleFrames[frameIndex]; + + final cells = _spriteToCells(charFrame); + + // 상점 앞에 캐릭터 배치 + const charX = 25; + final charY = frameHeight - cells.length - 1; + + return AsciiLayer( + cells: cells, + zIndex: 2, + offsetX: charX, + offsetY: charY, + ); + } + + /// 문자열 스프라이트를 AsciiCell 2D 배열로 변환 + List> _spriteToCells(List lines) { + return lines.map((line) { + return line.split('').map(AsciiCell.fromChar).toList(); + }).toList(); + } +} + +// ============================================================================ +// 상점 앞 대기 프레임 (4프레임 루프) - 물건 보는 동작 +// ============================================================================ + +const _shopIdleFrames = [ + // 프레임 1: 기본 + [ + r' o ', + r' /|\ ', + r' / \ ', + ], + // 프레임 2: 머리 숙임 + [ + r' o ', + r' /|~ ', + r' / \ ', + ], + // 프레임 3: 물건 보기 + [ + r' o? ', + r' /| ', + r' / \ ', + ], + // 프레임 4: 고개 끄덕 + [ + r' o! ', + r' /|\ ', + r' / \ ', + ], +]; diff --git a/lib/src/core/animation/canvas/canvas_walking_composer.dart b/lib/src/core/animation/canvas/canvas_walking_composer.dart new file mode 100644 index 0000000..6dd2b73 --- /dev/null +++ b/lib/src/core/animation/canvas/canvas_walking_composer.dart @@ -0,0 +1,113 @@ +import 'package:askiineverdie/src/core/animation/background_data.dart'; +import 'package:askiineverdie/src/core/animation/background_layer.dart'; +import 'package:askiineverdie/src/core/animation/canvas/ascii_cell.dart'; +import 'package:askiineverdie/src/core/animation/canvas/ascii_layer.dart'; + +/// Canvas용 걷기 애니메이션 합성기 +/// +/// 배경 스크롤 + 걷는 캐릭터 +class CanvasWalkingComposer { + const CanvasWalkingComposer(); + + /// 프레임 상수 + static const int frameWidth = 60; + static const int frameHeight = 8; + + /// 레이어 기반 프레임 생성 + List composeLayers(int globalTick) { + return [ + _createBackgroundLayer(globalTick), + _createCharacterLayer(globalTick), + ]; + } + + /// 배경 레이어 생성 (z=0) - 숲 환경 기본 + AsciiLayer _createBackgroundLayer(int globalTick) { + final cells = List.generate( + frameHeight, + (_) => List.filled(frameWidth, AsciiCell.empty), + ); + + final bgLayers = getBackgroundLayers(EnvironmentType.forest); + for (final layer in bgLayers) { + // 스크롤 오프셋 계산 (걷기는 더 빠른 스크롤) + final offset = (globalTick * layer.scrollSpeed * 2).toInt(); + + for (var i = 0; i < layer.lines.length; i++) { + final y = layer.yStart + i; + if (y >= frameHeight) break; + + final pattern = layer.lines[i]; + if (pattern.isEmpty) continue; + + for (var x = 0; x < frameWidth; x++) { + final patternIdx = (x + offset) % pattern.length; + final char = pattern[patternIdx]; + if (char != ' ') { + cells[y][x] = AsciiCell.fromChar(char); + } + } + } + } + + return AsciiLayer(cells: cells, zIndex: 0); + } + + /// 걷는 캐릭터 레이어 생성 (z=1) + AsciiLayer _createCharacterLayer(int globalTick) { + final frameIndex = globalTick % _walkingFrames.length; + final charFrame = _walkingFrames[frameIndex]; + + final cells = _spriteToCells(charFrame); + + // 화면 중앙에 캐릭터 배치 (25% 위치) + const charX = 15; + // 바닥 레이어(Y=7) 위에 서있도록 + final charY = frameHeight - cells.length - 1; + + return AsciiLayer( + cells: cells, + zIndex: 1, + offsetX: charX, + offsetY: charY, + ); + } + + /// 문자열 스프라이트를 AsciiCell 2D 배열로 변환 + List> _spriteToCells(List lines) { + return lines.map((line) { + return line.split('').map(AsciiCell.fromChar).toList(); + }).toList(); + } +} + +// ============================================================================ +// 걷기 프레임 (4프레임 루프) +// ============================================================================ + +const _walkingFrames = [ + // 프레임 1: 오른발 앞 + [ + r' o ', + r' /|\ ', + r' / | ', + ], + // 프레임 2: 모음 + [ + r' o ', + r' /|\ ', + r' / \ ', + ], + // 프레임 3: 왼발 앞 + [ + r' o ', + r' /|\ ', + r' | \ ', + ], + // 프레임 4: 모음 + [ + r' o ', + r' /|\ ', + r' / \ ', + ], +]; diff --git a/lib/src/core/engine/resurrection_service.dart b/lib/src/core/engine/resurrection_service.dart index 907f9b9..64133ce 100644 --- a/lib/src/core/engine/resurrection_service.dart +++ b/lib/src/core/engine/resurrection_service.dart @@ -31,12 +31,16 @@ class ResurrectionService { required String killerName, required DeathCause cause, }) { - // 제물로 바칠 아이템 선택 (장착된 아이템 중 랜덤 1개) + // 제물로 바칠 아이템 선택 (무기 제외, 장착된 아이템 중 랜덤 1개) final equippedItems = []; // 장착된 아이템의 슬롯 인덱스 for (var i = 0; i < Equipment.slotCount; i++) { + final slot = EquipmentSlot.values[i]; + // 무기 슬롯은 제외 + if (slot == EquipmentSlot.weapon) continue; + final item = state.equipment.getItemByIndex(i); - // 빈 슬롯과 기본 무기(Keyboard) 제외 - if (item.isNotEmpty && item.name != 'Keyboard') { + // 빈 슬롯 제외 + if (item.isNotEmpty) { equippedItems.add(i); } } @@ -130,6 +134,48 @@ class ResurrectionService { ), ); + // 4. 부활 후 태스크 시퀀스 설정 (큐에 추가) + // 순서: 마을 귀환 → 샵 정비 → 사냥터 이동 → 전투 + final resurrectionQueue = [ + const QueueEntry( + kind: QueueKind.task, + durationMillis: 3000, // 3초 + caption: 'Returning to town...', + taskType: TaskType.neutral, // 걷기 애니메이션 + ), + const QueueEntry( + kind: QueueKind.task, + durationMillis: 3000, // 3초 + caption: 'Restocking at shop...', + taskType: TaskType.market, // town 애니메이션 + ), + const QueueEntry( + kind: QueueKind.task, + durationMillis: 2000, // 2초 + caption: 'Heading to hunting grounds...', + taskType: TaskType.neutral, // 걷기 애니메이션 + ), + ]; + + // 기존 큐 초기화 후 부활 시퀀스만 설정 + nextState = nextState.copyWith( + queue: QueueState( + entries: resurrectionQueue, // 기존 큐 완전 제거 + ), + // 현재 태스크를 빈 상태로 설정하여 큐에서 다음 태스크를 가져오도록 함 + progress: nextState.progress.copyWith( + currentTask: const TaskInfo( + caption: '', + type: TaskType.neutral, + ), + task: const ProgressBarState( + position: 0, + max: 1, // 즉시 완료되어 큐에서 다음 태스크 가져옴 + ), + currentCombat: null, // 전투 상태 명시적 초기화 + ), + ); + return nextState; } diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index 5d805ab..f23553a 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -453,7 +453,24 @@ class _GamePlayScreenState extends State 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(); + } + }); }, ), ], diff --git a/lib/src/features/game/game_session_controller.dart b/lib/src/features/game/game_session_controller.dart index 00f6f30..56d1a8c 100644 --- a/lib/src/features/game/game_session_controller.dart +++ b/lib/src/features/game/game_session_controller.dart @@ -152,9 +152,10 @@ class GameSessionController extends ChangeNotifier { notifyListeners(); } - /// 플레이어 부활 처리 + /// 플레이어 부활 처리 (상태만 업데이트, 게임 재개는 별도로) /// - /// HP/MP 회복, 빈 슬롯에 장비 자동 구매, 게임 재개 + /// HP/MP 회복, 빈 슬롯에 장비 자동 구매 + /// 게임 재개는 resumeAfterResurrection()으로 별도 호출 필요 Future 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 resumeAfterResurrection() async { + if (_state == null) return; + // 게임 재개 - await startNew(resurrectedState, cheatsEnabled: _cheatsEnabled, isNewGame: false); + await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false); } /// 사망 상태 여부 diff --git a/lib/src/features/game/widgets/ascii_animation_card.dart b/lib/src/features/game/widgets/ascii_animation_card.dart index 2509977..804aa67 100644 --- a/lib/src/features/game/widgets/ascii_animation_card.dart +++ b/lib/src/features/game/widgets/ascii_animation_card.dart @@ -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 { 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 { 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 { _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 { /// 현재 상태를 유지하면서 타이머만 재시작 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 { // 특수 애니메이션이 있으면 우선 적용 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 { 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 { } 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 { super.dispose(); } - /// 이펙트 문자에 색상을 적용한 TextSpan 생성 - TextSpan _buildColoredTextSpan(String text, TextStyle baseStyle) { - final spans = []; - 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 _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 { 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 { 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()), ); } } diff --git a/lib/src/features/game/widgets/death_overlay.dart b/lib/src/features/game/widgets/death_overlay.dart index c48f975..8a6642a 100644 --- a/lib/src/features/game/widgets/death_overlay.dart +++ b/lib/src/features/game/widgets/death_overlay.dart @@ -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), - ], + ), ), ), ), diff --git a/lib/src/features/game/widgets/hp_mp_bar.dart b/lib/src/features/game/widgets/hp_mp_bar.dart index be2b511..7ebe9dc 100644 --- a/lib/src/features/game/widgets/hp_mp_bar.dart +++ b/lib/src/features/game/widgets/hp_mp_bar.dart @@ -369,11 +369,14 @@ class _HpMpBarState extends State 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, + ), ), ], ), diff --git a/pubspec.yaml b/pubspec.yaml index 44beb64..54bfef1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -89,5 +89,8 @@ flutter: # - asset: fonts/TrajanPro_Bold.ttf # weight: 700 # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + # 커스텀 모노스페이스 폰트 (Canvas ASCII 렌더링용) + fonts: + - family: JetBrainsMono + fonts: + - asset: assets/fonts/JetBrainsMono-Regular.ttf