feat(canvas): Canvas 기반 ASCII 애니메이션 렌더러 구현
- JetBrains Mono 폰트 번들링 (Android/iOS 호환성) - Paragraph 캐싱으로 GC 압박 최소화 (최대 256개 캐시) - shouldRepaint layerVersion 기반 최적화 - willChange 동적 설정으로 메모리 절약 - 레이어 기반 합성 구조 (배경/캐릭터/몬스터/이펙트) - hp_mp_bar 몬스터 HP 숫자 오버플로우 수정
This commit is contained in:
BIN
assets/fonts/JetBrainsMono-Regular.ttf
Normal file
BIN
assets/fonts/JetBrainsMono-Regular.ttf
Normal file
Binary file not shown.
@@ -693,6 +693,59 @@ const actCompleteAnimation = AsciiAnimationData(
|
|||||||
frameIntervalMs: 400,
|
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)
|
/// 타입별 애니메이션 데이터 반환 (기본 전투는 bug)
|
||||||
AsciiAnimationData getAnimationData(AsciiAnimationType type) {
|
AsciiAnimationData getAnimationData(AsciiAnimationType type) {
|
||||||
return switch (type) {
|
return switch (type) {
|
||||||
@@ -702,5 +755,6 @@ AsciiAnimationData getAnimationData(AsciiAnimationType type) {
|
|||||||
AsciiAnimationType.levelUp => levelUpAnimation,
|
AsciiAnimationType.levelUp => levelUpAnimation,
|
||||||
AsciiAnimationType.questComplete => questCompleteAnimation,
|
AsciiAnimationType.questComplete => questCompleteAnimation,
|
||||||
AsciiAnimationType.actComplete => actCompleteAnimation,
|
AsciiAnimationType.actComplete => actCompleteAnimation,
|
||||||
|
AsciiAnimationType.resurrection => resurrectionAnimation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ enum AsciiAnimationType {
|
|||||||
|
|
||||||
/// Act 완료 (플롯 진행)
|
/// Act 완료 (플롯 진행)
|
||||||
actComplete,
|
actComplete,
|
||||||
|
|
||||||
|
/// 부활 (사망 후)
|
||||||
|
resurrection,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TaskType을 AsciiAnimationType으로 변환
|
/// TaskType을 AsciiAnimationType으로 변환
|
||||||
|
|||||||
208
lib/src/core/animation/canvas/ascii_canvas_painter.dart
Normal file
208
lib/src/core/animation/canvas/ascii_canvas_painter.dart
Normal file
@@ -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<AsciiLayer> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
lib/src/core/animation/canvas/ascii_canvas_widget.dart
Normal file
52
lib/src/core/animation/canvas/ascii_canvas_widget.dart
Normal file
@@ -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<AsciiLayer> 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
lib/src/core/animation/canvas/ascii_cell.dart
Normal file
64
lib/src/core/animation/canvas/ascii_cell.dart
Normal file
@@ -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;
|
||||||
|
}
|
||||||
70
lib/src/core/animation/canvas/ascii_layer.dart
Normal file
70
lib/src/core/animation/canvas/ascii_layer.dart
Normal file
@@ -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<List<AsciiCell>> 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<String> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1014
lib/src/core/animation/canvas/canvas_battle_composer.dart
Normal file
1014
lib/src/core/animation/canvas/canvas_battle_composer.dart
Normal file
File diff suppressed because it is too large
Load Diff
269
lib/src/core/animation/canvas/canvas_special_composer.dart
Normal file
269
lib/src/core/animation/canvas/canvas_special_composer.dart
Normal file
@@ -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<AsciiLayer> 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<AsciiLayer> _composeLevelUp(int frameIndex, int globalTick) {
|
||||||
|
final layers = <AsciiLayer>[
|
||||||
|
_createEffectBackground(globalTick, '*'),
|
||||||
|
_createCenteredSprite(_levelUpFrames[frameIndex % _levelUpFrames.length]),
|
||||||
|
];
|
||||||
|
return layers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 퀘스트 완료 애니메이션
|
||||||
|
List<AsciiLayer> _composeQuestComplete(int frameIndex, int globalTick) {
|
||||||
|
final layers = <AsciiLayer>[
|
||||||
|
_createEffectBackground(globalTick, '+'),
|
||||||
|
_createCenteredSprite(
|
||||||
|
_questCompleteFrames[frameIndex % _questCompleteFrames.length]),
|
||||||
|
];
|
||||||
|
return layers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Act 완료 애니메이션
|
||||||
|
List<AsciiLayer> _composeActComplete(int frameIndex, int globalTick) {
|
||||||
|
final layers = <AsciiLayer>[
|
||||||
|
_createEffectBackground(globalTick, '~'),
|
||||||
|
_createCenteredSprite(
|
||||||
|
_actCompleteFrames[frameIndex % _actCompleteFrames.length]),
|
||||||
|
];
|
||||||
|
return layers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 부활 애니메이션
|
||||||
|
List<AsciiLayer> _composeResurrection(int frameIndex, int globalTick) {
|
||||||
|
final layers = <AsciiLayer>[
|
||||||
|
_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<String> 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<List<AsciiCell>> _spriteToCells(List<String> 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'___/ \___',
|
||||||
|
],
|
||||||
|
];
|
||||||
130
lib/src/core/animation/canvas/canvas_town_composer.dart
Normal file
130
lib/src/core/animation/canvas/canvas_town_composer.dart
Normal file
@@ -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<AsciiLayer> 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<List<AsciiCell>> _spriteToCells(List<String> 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' / \ ',
|
||||||
|
],
|
||||||
|
];
|
||||||
113
lib/src/core/animation/canvas/canvas_walking_composer.dart
Normal file
113
lib/src/core/animation/canvas/canvas_walking_composer.dart
Normal file
@@ -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<AsciiLayer> 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<List<AsciiCell>> _spriteToCells(List<String> 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' / \ ',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -31,12 +31,16 @@ class ResurrectionService {
|
|||||||
required String killerName,
|
required String killerName,
|
||||||
required DeathCause cause,
|
required DeathCause cause,
|
||||||
}) {
|
}) {
|
||||||
// 제물로 바칠 아이템 선택 (장착된 아이템 중 랜덤 1개)
|
// 제물로 바칠 아이템 선택 (무기 제외, 장착된 아이템 중 랜덤 1개)
|
||||||
final equippedItems = <int>[]; // 장착된 아이템의 슬롯 인덱스
|
final equippedItems = <int>[]; // 장착된 아이템의 슬롯 인덱스
|
||||||
for (var i = 0; i < Equipment.slotCount; i++) {
|
for (var i = 0; i < Equipment.slotCount; i++) {
|
||||||
|
final slot = EquipmentSlot.values[i];
|
||||||
|
// 무기 슬롯은 제외
|
||||||
|
if (slot == EquipmentSlot.weapon) continue;
|
||||||
|
|
||||||
final item = state.equipment.getItemByIndex(i);
|
final item = state.equipment.getItemByIndex(i);
|
||||||
// 빈 슬롯과 기본 무기(Keyboard) 제외
|
// 빈 슬롯 제외
|
||||||
if (item.isNotEmpty && item.name != 'Keyboard') {
|
if (item.isNotEmpty) {
|
||||||
equippedItems.add(i);
|
equippedItems.add(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,6 +134,48 @@ class ResurrectionService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 4. 부활 후 태스크 시퀀스 설정 (큐에 추가)
|
||||||
|
// 순서: 마을 귀환 → 샵 정비 → 사냥터 이동 → 전투
|
||||||
|
final resurrectionQueue = <QueueEntry>[
|
||||||
|
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;
|
return nextState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -453,7 +453,24 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
deathInfo: state.deathInfo!,
|
deathInfo: state.deathInfo!,
|
||||||
traits: state.traits,
|
traits: state.traits,
|
||||||
onResurrect: () async {
|
onResurrect: () async {
|
||||||
|
// 1. 부활 처리 (HP/MP 회복, 장비 구매 - 게임 재개 없음)
|
||||||
await widget.controller.resurrect();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -152,9 +152,10 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 플레이어 부활 처리
|
/// 플레이어 부활 처리 (상태만 업데이트, 게임 재개는 별도로)
|
||||||
///
|
///
|
||||||
/// HP/MP 회복, 빈 슬롯에 장비 자동 구매, 게임 재개
|
/// HP/MP 회복, 빈 슬롯에 장비 자동 구매
|
||||||
|
/// 게임 재개는 resumeAfterResurrection()으로 별도 호출 필요
|
||||||
Future<void> resurrect() async {
|
Future<void> resurrect() async {
|
||||||
if (_state == null || !_state!.isDead) return;
|
if (_state == null || !_state!.isDead) return;
|
||||||
|
|
||||||
@@ -164,11 +165,24 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
|
|
||||||
final resurrectedState = resurrectionService.processResurrection(_state!);
|
final resurrectedState = resurrectionService.processResurrection(_state!);
|
||||||
|
|
||||||
|
// 상태 업데이트 (게임 재개 없이)
|
||||||
|
_state = resurrectedState;
|
||||||
|
_status = GameSessionStatus.idle; // 사망 상태 해제
|
||||||
|
|
||||||
// 저장
|
// 저장
|
||||||
await saveManager.saveState(resurrectedState);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 사망 상태 여부
|
/// 사망 상태 여부
|
||||||
|
|||||||
@@ -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_data.dart';
|
||||||
import 'package:askiineverdie/src/core/animation/ascii_animation_type.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/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/character_frames.dart';
|
||||||
import 'package:askiineverdie/src/core/animation/monster_size.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_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/combat_event.dart';
|
||||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||||
|
|
||||||
/// ASCII 애니메이션 카드 위젯
|
/// 애니메이션 모드
|
||||||
|
enum AnimationMode {
|
||||||
|
battle, // 전투
|
||||||
|
walking, // 걷기
|
||||||
|
town, // 마을/상점
|
||||||
|
special, // 특수 이벤트
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ASCII 애니메이션 카드 위젯 (전체 Canvas 기반)
|
||||||
///
|
///
|
||||||
/// TaskType에 따라 다른 애니메이션을 표시.
|
/// TaskType에 따라 다른 애니메이션을 표시.
|
||||||
/// 전투 시 몬스터 이름에 따라 다른 애니메이션 선택.
|
/// 전투 시 몬스터 이름에 따라 다른 애니메이션 선택.
|
||||||
@@ -69,14 +82,20 @@ class AsciiAnimationCard extends StatefulWidget {
|
|||||||
class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
int _currentFrame = 0;
|
int _currentFrame = 0;
|
||||||
late AsciiAnimationData _animationData;
|
|
||||||
AsciiAnimationType? _currentSpecialAnimation;
|
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;
|
BattlePhase _battlePhase = BattlePhase.idle;
|
||||||
int _battleSubFrame = 0;
|
int _battleSubFrame = 0;
|
||||||
BattleComposer? _battleComposer;
|
|
||||||
|
|
||||||
// 글로벌 틱 (배경 스크롤용)
|
// 글로벌 틱 (배경 스크롤용)
|
||||||
int _globalTick = 0;
|
int _globalTick = 0;
|
||||||
@@ -99,6 +118,14 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
int? _lastEventTimestamp;
|
int? _lastEventTimestamp;
|
||||||
bool _showCriticalEffect = false;
|
bool _showCriticalEffect = false;
|
||||||
|
|
||||||
|
// 특수 애니메이션 프레임 수
|
||||||
|
static const _specialAnimationFrameCounts = {
|
||||||
|
AsciiAnimationType.levelUp: 5,
|
||||||
|
AsciiAnimationType.questComplete: 4,
|
||||||
|
AsciiAnimationType.actComplete: 4,
|
||||||
|
AsciiAnimationType.resurrection: 5,
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -153,7 +180,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
_lastEventTimestamp = event.timestamp;
|
_lastEventTimestamp = event.timestamp;
|
||||||
|
|
||||||
// 전투 모드가 아니면 무시
|
// 전투 모드가 아니면 무시
|
||||||
if (!_isBattleMode) return;
|
if (_animationMode != AnimationMode.battle) return;
|
||||||
|
|
||||||
// 이벤트 타입에 따라 페이즈 강제 전환
|
// 이벤트 타입에 따라 페이즈 강제 전환
|
||||||
final (targetPhase, isCritical) = switch (event.type) {
|
final (targetPhase, isCritical) = switch (event.type) {
|
||||||
@@ -190,46 +217,34 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
/// 현재 상태를 유지하면서 타이머만 재시작
|
/// 현재 상태를 유지하면서 타이머만 재시작
|
||||||
void _restartTimer() {
|
void _restartTimer() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
|
_startTimer();
|
||||||
|
}
|
||||||
|
|
||||||
// 특수 애니메이션 타이머 재시작
|
/// 타이머 시작
|
||||||
if (_currentSpecialAnimation != null) {
|
void _startTimer() {
|
||||||
_timer = Timer.periodic(
|
const tickInterval = Duration(milliseconds: 200);
|
||||||
Duration(milliseconds: _animationData.frameIntervalMs),
|
|
||||||
(_) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_currentFrame++;
|
|
||||||
if (_currentFrame >= _animationData.frames.length) {
|
|
||||||
_currentSpecialAnimation = null;
|
|
||||||
_updateAnimation();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전투 모드 타이머 재시작
|
_timer = Timer.periodic(tickInterval, (_) {
|
||||||
if (_isBattleMode) {
|
if (!mounted) return;
|
||||||
_timer = Timer.periodic(
|
|
||||||
const Duration(milliseconds: 200),
|
setState(() {
|
||||||
(_) => _advanceBattleFrame(),
|
_globalTick++;
|
||||||
);
|
|
||||||
} else {
|
if (_animationMode == AnimationMode.special) {
|
||||||
// 일반 애니메이션 타이머 재시작
|
_currentFrame++;
|
||||||
_timer = Timer.periodic(
|
final maxFrames =
|
||||||
Duration(milliseconds: _animationData.frameIntervalMs),
|
_specialAnimationFrameCounts[_currentSpecialAnimation] ?? 5;
|
||||||
(_) {
|
// 마지막 프레임에 도달하면 특수 애니메이션 종료
|
||||||
if (mounted) {
|
if (_currentFrame >= maxFrames) {
|
||||||
setState(() {
|
_currentSpecialAnimation = null;
|
||||||
_currentFrame =
|
_updateAnimation();
|
||||||
(_currentFrame + 1) % _animationData.frames.length;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
} else if (_animationMode == AnimationMode.battle) {
|
||||||
);
|
_advanceBattleFrame();
|
||||||
}
|
}
|
||||||
|
// walking, town은 globalTick만 증가하면 됨
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateAnimation() {
|
void _updateAnimation() {
|
||||||
@@ -237,71 +252,40 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
|
|
||||||
// 특수 애니메이션이 있으면 우선 적용
|
// 특수 애니메이션이 있으면 우선 적용
|
||||||
if (_currentSpecialAnimation != null) {
|
if (_currentSpecialAnimation != null) {
|
||||||
_isBattleMode = false;
|
_animationMode = AnimationMode.special;
|
||||||
_animationData = getAnimationData(_currentSpecialAnimation!);
|
|
||||||
_currentFrame = 0;
|
_currentFrame = 0;
|
||||||
|
|
||||||
// 일시정지 상태면 타이머 시작하지 않음
|
// 특수 애니메이션은 게임 일시정지와 무관하게 항상 재생
|
||||||
if (widget.isPaused) return;
|
_startTimer();
|
||||||
|
|
||||||
// 특수 애니메이션은 한 번 재생 후 종료
|
|
||||||
_timer = Timer.periodic(
|
|
||||||
Duration(milliseconds: _animationData.frameIntervalMs),
|
|
||||||
(_) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_currentFrame++;
|
|
||||||
// 마지막 프레임에 도달하면 특수 애니메이션 종료
|
|
||||||
if (_currentFrame >= _animationData.frames.length) {
|
|
||||||
_currentSpecialAnimation = null;
|
|
||||||
_updateAnimation();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일반 애니메이션 처리
|
// 일반 애니메이션 처리
|
||||||
final animationType = taskTypeToAnimation(widget.taskType);
|
final animationType = taskTypeToAnimation(widget.taskType);
|
||||||
|
|
||||||
// 전투 타입이면 새 BattleComposer 시스템 사용
|
switch (animationType) {
|
||||||
if (animationType == AsciiAnimationType.battle) {
|
case AsciiAnimationType.battle:
|
||||||
_isBattleMode = true;
|
_animationMode = AnimationMode.battle;
|
||||||
_setupBattleComposer();
|
_setupBattleComposer();
|
||||||
_battlePhase = BattlePhase.idle;
|
_battlePhase = BattlePhase.idle;
|
||||||
_battleSubFrame = 0;
|
_battleSubFrame = 0;
|
||||||
_phaseIndex = 0;
|
_phaseIndex = 0;
|
||||||
_phaseFrameCount = 0;
|
_phaseFrameCount = 0;
|
||||||
|
|
||||||
// 일시정지 상태면 타이머 시작하지 않음
|
case AsciiAnimationType.town:
|
||||||
if (widget.isPaused) return;
|
_animationMode = AnimationMode.town;
|
||||||
|
|
||||||
_timer = Timer.periodic(
|
case AsciiAnimationType.walking:
|
||||||
const Duration(milliseconds: 200),
|
_animationMode = AnimationMode.walking;
|
||||||
(_) => _advanceBattleFrame(),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
_isBattleMode = false;
|
|
||||||
_animationData = getAnimationData(animationType);
|
|
||||||
_currentFrame = 0;
|
|
||||||
|
|
||||||
// 일시정지 상태면 타이머 시작하지 않음
|
default:
|
||||||
if (widget.isPaused) return;
|
_animationMode = AnimationMode.walking;
|
||||||
|
|
||||||
_timer = Timer.periodic(
|
|
||||||
Duration(milliseconds: _animationData.frameIntervalMs),
|
|
||||||
(_) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_currentFrame =
|
|
||||||
(_currentFrame + 1) % _animationData.frames.length;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 일시정지 상태면 타이머 시작하지 않음
|
||||||
|
if (widget.isPaused) return;
|
||||||
|
|
||||||
|
_startTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setupBattleComposer() {
|
void _setupBattleComposer() {
|
||||||
@@ -311,7 +295,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
final monsterCategory = getMonsterCategory(widget.monsterBaseName);
|
final monsterCategory = getMonsterCategory(widget.monsterBaseName);
|
||||||
final monsterSize = getMonsterSize(widget.monsterLevel);
|
final monsterSize = getMonsterSize(widget.monsterLevel);
|
||||||
|
|
||||||
_battleComposer = BattleComposer(
|
_battleComposer = CanvasBattleComposer(
|
||||||
weaponCategory: weaponCategory,
|
weaponCategory: weaponCategory,
|
||||||
hasShield: hasShield,
|
hasShield: hasShield,
|
||||||
monsterCategory: monsterCategory,
|
monsterCategory: monsterCategory,
|
||||||
@@ -326,28 +310,21 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _advanceBattleFrame() {
|
void _advanceBattleFrame() {
|
||||||
if (!mounted) return;
|
_phaseFrameCount++;
|
||||||
|
final currentPhase = _battlePhaseSequence[_phaseIndex];
|
||||||
|
|
||||||
setState(() {
|
// 현재 페이즈의 프레임 수를 초과하면 다음 페이즈로
|
||||||
// 글로벌 틱 증가 (배경 스크롤용)
|
if (_phaseFrameCount >= currentPhase.$2) {
|
||||||
_globalTick++;
|
_phaseIndex = (_phaseIndex + 1) % _battlePhaseSequence.length;
|
||||||
|
_phaseFrameCount = 0;
|
||||||
|
_battleSubFrame = 0;
|
||||||
|
// 크리티컬 이펙트 리셋 (페이즈 전환 시)
|
||||||
|
_showCriticalEffect = false;
|
||||||
|
} else {
|
||||||
|
_battleSubFrame++;
|
||||||
|
}
|
||||||
|
|
||||||
_phaseFrameCount++;
|
_battlePhase = _battlePhaseSequence[_phaseIndex].$1;
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -356,52 +333,24 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 이펙트 문자에 색상을 적용한 TextSpan 생성
|
/// 현재 애니메이션 레이어 생성
|
||||||
TextSpan _buildColoredTextSpan(String text, TextStyle baseStyle) {
|
List<AsciiLayer> _composeLayers() {
|
||||||
final spans = <TextSpan>[];
|
return switch (_animationMode) {
|
||||||
final buffer = StringBuffer();
|
AnimationMode.battle => _battleComposer?.composeLayers(
|
||||||
|
_battlePhase,
|
||||||
// 이펙트 문자 정의
|
_battleSubFrame,
|
||||||
const effectChars = {'*', '!', '=', '>', '<', '~'};
|
widget.monsterBaseName,
|
||||||
|
_environment,
|
||||||
for (var i = 0; i < text.length; i++) {
|
_globalTick,
|
||||||
final char = text[i];
|
) ??
|
||||||
|
[AsciiLayer.empty()],
|
||||||
if (effectChars.contains(char)) {
|
AnimationMode.walking => _walkingComposer.composeLayers(_globalTick),
|
||||||
// 버퍼에 쌓인 일반 텍스트 추가
|
AnimationMode.town => _townComposer.composeLayers(_globalTick),
|
||||||
if (buffer.isNotEmpty) {
|
AnimationMode.special => _specialComposer.composeLayers(
|
||||||
spans.add(TextSpan(text: buffer.toString(), style: baseStyle));
|
_currentSpecialAnimation ?? AsciiAnimationType.levelUp,
|
||||||
buffer.clear();
|
_currentFrame,
|
||||||
}
|
_globalTick,
|
||||||
|
),
|
||||||
// 이펙트 문자에 색상 적용
|
|
||||||
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, // 오브젝트 (흰색)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,38 +358,18 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Phase 7: 고정 4색 팔레트 사용 (colorTheme 무시)
|
// Phase 7: 고정 4색 팔레트 사용 (colorTheme 무시)
|
||||||
const bgColor = AsciiColors.background;
|
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;
|
final isSpecial = _currentSpecialAnimation != null;
|
||||||
Border? borderEffect;
|
Border? borderEffect;
|
||||||
if (_showCriticalEffect) {
|
if (_showCriticalEffect) {
|
||||||
// 크리티컬 히트: 노란색 테두리 (Phase 5)
|
// 크리티컬 히트: 노란색 테두리 (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) {
|
} 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(
|
return Container(
|
||||||
@@ -450,51 +379,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
border: borderEffect,
|
border: borderEffect,
|
||||||
),
|
),
|
||||||
child: _isBattleMode
|
child: AsciiCanvasWidget(layers: _composeLayers()),
|
||||||
? 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,41 +50,43 @@ class DeathOverlay extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: SingleChildScrollView(
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
// 사망 타이틀
|
children: [
|
||||||
_buildDeathTitle(context),
|
// 사망 타이틀
|
||||||
const SizedBox(height: 16),
|
_buildDeathTitle(context),
|
||||||
|
|
||||||
// 캐릭터 정보
|
|
||||||
_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) ...[
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 캐릭터 정보
|
||||||
|
_buildCharacterInfo(context),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 사망 원인
|
||||||
|
_buildDeathCause(context),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 구분선
|
||||||
Divider(color: colorScheme.outlineVariant),
|
Divider(color: colorScheme.outlineVariant),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 16),
|
||||||
_buildCombatLog(context),
|
|
||||||
|
// 상실 정보
|
||||||
|
_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),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -369,11 +369,14 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
|
|
||||||
// HP 숫자
|
// HP 숫자 (Flexible로 오버플로우 방지)
|
||||||
Text(
|
Flexible(
|
||||||
'$current/$max',
|
child: Text(
|
||||||
style: const TextStyle(fontSize: 8, color: Colors.orange),
|
'$current/$max',
|
||||||
textAlign: TextAlign.right,
|
style: const TextStyle(fontSize: 8, color: Colors.orange),
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -89,5 +89,8 @@ flutter:
|
|||||||
# - asset: fonts/TrajanPro_Bold.ttf
|
# - asset: fonts/TrajanPro_Bold.ttf
|
||||||
# weight: 700
|
# weight: 700
|
||||||
#
|
#
|
||||||
# For details regarding fonts from package dependencies,
|
# 커스텀 모노스페이스 폰트 (Canvas ASCII 렌더링용)
|
||||||
# see https://flutter.dev/to/font-from-package
|
fonts:
|
||||||
|
- family: JetBrainsMono
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/JetBrainsMono-Regular.ttf
|
||||||
|
|||||||
Reference in New Issue
Block a user