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,
|
||||
);
|
||||
|
||||
/// 부활 애니메이션 (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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ enum AsciiAnimationType {
|
||||
|
||||
/// Act 완료 (플롯 진행)
|
||||
actComplete,
|
||||
|
||||
/// 부활 (사망 후)
|
||||
resurrection,
|
||||
}
|
||||
|
||||
/// 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 DeathCause cause,
|
||||
}) {
|
||||
// 제물로 바칠 아이템 선택 (장착된 아이템 중 랜덤 1개)
|
||||
// 제물로 바칠 아이템 선택 (무기 제외, 장착된 아이템 중 랜덤 1개)
|
||||
final equippedItems = <int>[]; // 장착된 아이템의 슬롯 인덱스
|
||||
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 = <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;
|
||||
}
|
||||
|
||||
|
||||
@@ -453,7 +453,24 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
deathInfo: state.deathInfo!,
|
||||
traits: state.traits,
|
||||
onResurrect: () async {
|
||||
// 1. 부활 처리 (HP/MP 회복, 장비 구매 - 게임 재개 없음)
|
||||
await widget.controller.resurrect();
|
||||
|
||||
// 2. 부활 애니메이션 재생
|
||||
setState(() {
|
||||
_specialAnimation = AsciiAnimationType.resurrection;
|
||||
});
|
||||
|
||||
// 3. 애니메이션 종료 후 게임 재개 (5프레임 × 600ms = 3초)
|
||||
Future.delayed(const Duration(milliseconds: 3000), () async {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_specialAnimation = null;
|
||||
});
|
||||
// 부활 후 게임 재개 (새 루프 시작)
|
||||
await widget.controller.resumeAfterResurrection();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -152,9 +152,10 @@ class GameSessionController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 플레이어 부활 처리
|
||||
/// 플레이어 부활 처리 (상태만 업데이트, 게임 재개는 별도로)
|
||||
///
|
||||
/// HP/MP 회복, 빈 슬롯에 장비 자동 구매, 게임 재개
|
||||
/// HP/MP 회복, 빈 슬롯에 장비 자동 구매
|
||||
/// 게임 재개는 resumeAfterResurrection()으로 별도 호출 필요
|
||||
Future<void> resurrect() async {
|
||||
if (_state == null || !_state!.isDead) return;
|
||||
|
||||
@@ -164,11 +165,24 @@ class GameSessionController extends ChangeNotifier {
|
||||
|
||||
final resurrectedState = resurrectionService.processResurrection(_state!);
|
||||
|
||||
// 상태 업데이트 (게임 재개 없이)
|
||||
_state = resurrectedState;
|
||||
_status = GameSessionStatus.idle; // 사망 상태 해제
|
||||
|
||||
// 저장
|
||||
await saveManager.saveState(resurrectedState);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 부활 후 게임 재개
|
||||
///
|
||||
/// resurrect() 호출 후 애니메이션이 끝난 뒤 호출
|
||||
Future<void> resumeAfterResurrection() async {
|
||||
if (_state == null) return;
|
||||
|
||||
// 게임 재개
|
||||
await startNew(resurrectedState, cheatsEnabled: _cheatsEnabled, isNewGame: false);
|
||||
await startNew(_state!, cheatsEnabled: _cheatsEnabled, isNewGame: false);
|
||||
}
|
||||
|
||||
/// 사망 상태 여부
|
||||
|
||||
@@ -5,7 +5,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:askiineverdie/src/core/animation/ascii_animation_data.dart';
|
||||
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
|
||||
import 'package:askiineverdie/src/core/animation/background_layer.dart';
|
||||
import 'package:askiineverdie/src/core/animation/battle_composer.dart';
|
||||
import 'package:askiineverdie/src/core/animation/canvas/ascii_canvas_widget.dart';
|
||||
import 'package:askiineverdie/src/core/animation/canvas/ascii_layer.dart';
|
||||
import 'package:askiineverdie/src/core/animation/canvas/canvas_battle_composer.dart';
|
||||
import 'package:askiineverdie/src/core/animation/canvas/canvas_special_composer.dart';
|
||||
import 'package:askiineverdie/src/core/animation/canvas/canvas_town_composer.dart';
|
||||
import 'package:askiineverdie/src/core/animation/canvas/canvas_walking_composer.dart';
|
||||
import 'package:askiineverdie/src/core/animation/character_frames.dart';
|
||||
import 'package:askiineverdie/src/core/animation/monster_size.dart';
|
||||
import 'package:askiineverdie/src/core/animation/weapon_category.dart';
|
||||
@@ -13,7 +18,15 @@ import 'package:askiineverdie/src/core/constants/ascii_colors.dart';
|
||||
import 'package:askiineverdie/src/core/model/combat_event.dart';
|
||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
|
||||
/// ASCII 애니메이션 카드 위젯
|
||||
/// 애니메이션 모드
|
||||
enum AnimationMode {
|
||||
battle, // 전투
|
||||
walking, // 걷기
|
||||
town, // 마을/상점
|
||||
special, // 특수 이벤트
|
||||
}
|
||||
|
||||
/// ASCII 애니메이션 카드 위젯 (전체 Canvas 기반)
|
||||
///
|
||||
/// TaskType에 따라 다른 애니메이션을 표시.
|
||||
/// 전투 시 몬스터 이름에 따라 다른 애니메이션 선택.
|
||||
@@ -69,14 +82,20 @@ class AsciiAnimationCard extends StatefulWidget {
|
||||
class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
Timer? _timer;
|
||||
int _currentFrame = 0;
|
||||
late AsciiAnimationData _animationData;
|
||||
AsciiAnimationType? _currentSpecialAnimation;
|
||||
|
||||
// 애니메이션 모드
|
||||
AnimationMode _animationMode = AnimationMode.walking;
|
||||
|
||||
// Composer 인스턴스들
|
||||
CanvasBattleComposer? _battleComposer;
|
||||
final _walkingComposer = const CanvasWalkingComposer();
|
||||
final _townComposer = const CanvasTownComposer();
|
||||
final _specialComposer = const CanvasSpecialComposer();
|
||||
|
||||
// 전투 애니메이션 상태
|
||||
bool _isBattleMode = false;
|
||||
BattlePhase _battlePhase = BattlePhase.idle;
|
||||
int _battleSubFrame = 0;
|
||||
BattleComposer? _battleComposer;
|
||||
|
||||
// 글로벌 틱 (배경 스크롤용)
|
||||
int _globalTick = 0;
|
||||
@@ -99,6 +118,14 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
int? _lastEventTimestamp;
|
||||
bool _showCriticalEffect = false;
|
||||
|
||||
// 특수 애니메이션 프레임 수
|
||||
static const _specialAnimationFrameCounts = {
|
||||
AsciiAnimationType.levelUp: 5,
|
||||
AsciiAnimationType.questComplete: 4,
|
||||
AsciiAnimationType.actComplete: 4,
|
||||
AsciiAnimationType.resurrection: 5,
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -153,7 +180,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
_lastEventTimestamp = event.timestamp;
|
||||
|
||||
// 전투 모드가 아니면 무시
|
||||
if (!_isBattleMode) return;
|
||||
if (_animationMode != AnimationMode.battle) return;
|
||||
|
||||
// 이벤트 타입에 따라 페이즈 강제 전환
|
||||
final (targetPhase, isCritical) = switch (event.type) {
|
||||
@@ -190,46 +217,34 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
/// 현재 상태를 유지하면서 타이머만 재시작
|
||||
void _restartTimer() {
|
||||
_timer?.cancel();
|
||||
_startTimer();
|
||||
}
|
||||
|
||||
// 특수 애니메이션 타이머 재시작
|
||||
if (_currentSpecialAnimation != null) {
|
||||
_timer = Timer.periodic(
|
||||
Duration(milliseconds: _animationData.frameIntervalMs),
|
||||
(_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentFrame++;
|
||||
if (_currentFrame >= _animationData.frames.length) {
|
||||
_currentSpecialAnimation = null;
|
||||
_updateAnimation();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
/// 타이머 시작
|
||||
void _startTimer() {
|
||||
const tickInterval = Duration(milliseconds: 200);
|
||||
|
||||
// 전투 모드 타이머 재시작
|
||||
if (_isBattleMode) {
|
||||
_timer = Timer.periodic(
|
||||
const Duration(milliseconds: 200),
|
||||
(_) => _advanceBattleFrame(),
|
||||
);
|
||||
} else {
|
||||
// 일반 애니메이션 타이머 재시작
|
||||
_timer = Timer.periodic(
|
||||
Duration(milliseconds: _animationData.frameIntervalMs),
|
||||
(_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentFrame =
|
||||
(_currentFrame + 1) % _animationData.frames.length;
|
||||
});
|
||||
_timer = Timer.periodic(tickInterval, (_) {
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_globalTick++;
|
||||
|
||||
if (_animationMode == AnimationMode.special) {
|
||||
_currentFrame++;
|
||||
final maxFrames =
|
||||
_specialAnimationFrameCounts[_currentSpecialAnimation] ?? 5;
|
||||
// 마지막 프레임에 도달하면 특수 애니메이션 종료
|
||||
if (_currentFrame >= maxFrames) {
|
||||
_currentSpecialAnimation = null;
|
||||
_updateAnimation();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
} else if (_animationMode == AnimationMode.battle) {
|
||||
_advanceBattleFrame();
|
||||
}
|
||||
// walking, town은 globalTick만 증가하면 됨
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _updateAnimation() {
|
||||
@@ -237,71 +252,40 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
|
||||
// 특수 애니메이션이 있으면 우선 적용
|
||||
if (_currentSpecialAnimation != null) {
|
||||
_isBattleMode = false;
|
||||
_animationData = getAnimationData(_currentSpecialAnimation!);
|
||||
_animationMode = AnimationMode.special;
|
||||
_currentFrame = 0;
|
||||
|
||||
// 일시정지 상태면 타이머 시작하지 않음
|
||||
if (widget.isPaused) return;
|
||||
|
||||
// 특수 애니메이션은 한 번 재생 후 종료
|
||||
_timer = Timer.periodic(
|
||||
Duration(milliseconds: _animationData.frameIntervalMs),
|
||||
(_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentFrame++;
|
||||
// 마지막 프레임에 도달하면 특수 애니메이션 종료
|
||||
if (_currentFrame >= _animationData.frames.length) {
|
||||
_currentSpecialAnimation = null;
|
||||
_updateAnimation();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
// 특수 애니메이션은 게임 일시정지와 무관하게 항상 재생
|
||||
_startTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
// 일반 애니메이션 처리
|
||||
final animationType = taskTypeToAnimation(widget.taskType);
|
||||
|
||||
// 전투 타입이면 새 BattleComposer 시스템 사용
|
||||
if (animationType == AsciiAnimationType.battle) {
|
||||
_isBattleMode = true;
|
||||
_setupBattleComposer();
|
||||
_battlePhase = BattlePhase.idle;
|
||||
_battleSubFrame = 0;
|
||||
_phaseIndex = 0;
|
||||
_phaseFrameCount = 0;
|
||||
switch (animationType) {
|
||||
case AsciiAnimationType.battle:
|
||||
_animationMode = AnimationMode.battle;
|
||||
_setupBattleComposer();
|
||||
_battlePhase = BattlePhase.idle;
|
||||
_battleSubFrame = 0;
|
||||
_phaseIndex = 0;
|
||||
_phaseFrameCount = 0;
|
||||
|
||||
// 일시정지 상태면 타이머 시작하지 않음
|
||||
if (widget.isPaused) return;
|
||||
case AsciiAnimationType.town:
|
||||
_animationMode = AnimationMode.town;
|
||||
|
||||
_timer = Timer.periodic(
|
||||
const Duration(milliseconds: 200),
|
||||
(_) => _advanceBattleFrame(),
|
||||
);
|
||||
} else {
|
||||
_isBattleMode = false;
|
||||
_animationData = getAnimationData(animationType);
|
||||
_currentFrame = 0;
|
||||
case AsciiAnimationType.walking:
|
||||
_animationMode = AnimationMode.walking;
|
||||
|
||||
// 일시정지 상태면 타이머 시작하지 않음
|
||||
if (widget.isPaused) return;
|
||||
|
||||
_timer = Timer.periodic(
|
||||
Duration(milliseconds: _animationData.frameIntervalMs),
|
||||
(_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentFrame =
|
||||
(_currentFrame + 1) % _animationData.frames.length;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
default:
|
||||
_animationMode = AnimationMode.walking;
|
||||
}
|
||||
|
||||
// 일시정지 상태면 타이머 시작하지 않음
|
||||
if (widget.isPaused) return;
|
||||
|
||||
_startTimer();
|
||||
}
|
||||
|
||||
void _setupBattleComposer() {
|
||||
@@ -311,7 +295,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
final monsterCategory = getMonsterCategory(widget.monsterBaseName);
|
||||
final monsterSize = getMonsterSize(widget.monsterLevel);
|
||||
|
||||
_battleComposer = BattleComposer(
|
||||
_battleComposer = CanvasBattleComposer(
|
||||
weaponCategory: weaponCategory,
|
||||
hasShield: hasShield,
|
||||
monsterCategory: monsterCategory,
|
||||
@@ -326,28 +310,21 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
}
|
||||
|
||||
void _advanceBattleFrame() {
|
||||
if (!mounted) return;
|
||||
_phaseFrameCount++;
|
||||
final currentPhase = _battlePhaseSequence[_phaseIndex];
|
||||
|
||||
setState(() {
|
||||
// 글로벌 틱 증가 (배경 스크롤용)
|
||||
_globalTick++;
|
||||
// 현재 페이즈의 프레임 수를 초과하면 다음 페이즈로
|
||||
if (_phaseFrameCount >= currentPhase.$2) {
|
||||
_phaseIndex = (_phaseIndex + 1) % _battlePhaseSequence.length;
|
||||
_phaseFrameCount = 0;
|
||||
_battleSubFrame = 0;
|
||||
// 크리티컬 이펙트 리셋 (페이즈 전환 시)
|
||||
_showCriticalEffect = false;
|
||||
} else {
|
||||
_battleSubFrame++;
|
||||
}
|
||||
|
||||
_phaseFrameCount++;
|
||||
final currentPhase = _battlePhaseSequence[_phaseIndex];
|
||||
|
||||
// 현재 페이즈의 프레임 수를 초과하면 다음 페이즈로
|
||||
if (_phaseFrameCount >= currentPhase.$2) {
|
||||
_phaseIndex = (_phaseIndex + 1) % _battlePhaseSequence.length;
|
||||
_phaseFrameCount = 0;
|
||||
_battleSubFrame = 0;
|
||||
// 크리티컬 이펙트 리셋 (페이즈 전환 시)
|
||||
_showCriticalEffect = false;
|
||||
} else {
|
||||
_battleSubFrame++;
|
||||
}
|
||||
|
||||
_battlePhase = _battlePhaseSequence[_phaseIndex].$1;
|
||||
});
|
||||
_battlePhase = _battlePhaseSequence[_phaseIndex].$1;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -356,52 +333,24 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 이펙트 문자에 색상을 적용한 TextSpan 생성
|
||||
TextSpan _buildColoredTextSpan(String text, TextStyle baseStyle) {
|
||||
final spans = <TextSpan>[];
|
||||
final buffer = StringBuffer();
|
||||
|
||||
// 이펙트 문자 정의
|
||||
const effectChars = {'*', '!', '=', '>', '<', '~'};
|
||||
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
final char = text[i];
|
||||
|
||||
if (effectChars.contains(char)) {
|
||||
// 버퍼에 쌓인 일반 텍스트 추가
|
||||
if (buffer.isNotEmpty) {
|
||||
spans.add(TextSpan(text: buffer.toString(), style: baseStyle));
|
||||
buffer.clear();
|
||||
}
|
||||
|
||||
// 이펙트 문자에 색상 적용
|
||||
final effectColor = _getEffectColor(char);
|
||||
spans.add(TextSpan(
|
||||
text: char,
|
||||
style: baseStyle.copyWith(color: effectColor),
|
||||
));
|
||||
} else {
|
||||
buffer.write(char);
|
||||
}
|
||||
}
|
||||
|
||||
// 남은 일반 텍스트 추가
|
||||
if (buffer.isNotEmpty) {
|
||||
spans.add(TextSpan(text: buffer.toString(), style: baseStyle));
|
||||
}
|
||||
|
||||
return TextSpan(children: spans);
|
||||
}
|
||||
|
||||
/// 이펙트 문자별 색상 반환 (Phase 7: 4색 팔레트)
|
||||
Color _getEffectColor(String char) {
|
||||
return switch (char) {
|
||||
'*' => AsciiColors.negative, // 히트/폭발 (마젠타)
|
||||
'!' => AsciiColors.positive, // 강조 (시안)
|
||||
'=' || '>' || '<' => AsciiColors.positive, // 슬래시/찌르기 (시안)
|
||||
'~' => AsciiColors.negative, // 물결/디버프 (마젠타)
|
||||
'+' => AsciiColors.positive, // 회복/버프 (시안)
|
||||
_ => AsciiColors.object, // 오브젝트 (흰색)
|
||||
/// 현재 애니메이션 레이어 생성
|
||||
List<AsciiLayer> _composeLayers() {
|
||||
return switch (_animationMode) {
|
||||
AnimationMode.battle => _battleComposer?.composeLayers(
|
||||
_battlePhase,
|
||||
_battleSubFrame,
|
||||
widget.monsterBaseName,
|
||||
_environment,
|
||||
_globalTick,
|
||||
) ??
|
||||
[AsciiLayer.empty()],
|
||||
AnimationMode.walking => _walkingComposer.composeLayers(_globalTick),
|
||||
AnimationMode.town => _townComposer.composeLayers(_globalTick),
|
||||
AnimationMode.special => _specialComposer.composeLayers(
|
||||
_currentSpecialAnimation ?? AsciiAnimationType.levelUp,
|
||||
_currentFrame,
|
||||
_globalTick,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -409,38 +358,18 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
Widget build(BuildContext context) {
|
||||
// Phase 7: 고정 4색 팔레트 사용 (colorTheme 무시)
|
||||
const bgColor = AsciiColors.background;
|
||||
const textColor = AsciiColors.object;
|
||||
|
||||
// 프레임 텍스트 결정
|
||||
String frameText;
|
||||
|
||||
if (_isBattleMode && _battleComposer != null) {
|
||||
// 새 배틀 시스템 사용 (배경 포함)
|
||||
frameText = _battleComposer!.composeFrameWithBackground(
|
||||
_battlePhase,
|
||||
_battleSubFrame,
|
||||
widget.monsterBaseName,
|
||||
_environment,
|
||||
_globalTick,
|
||||
);
|
||||
// 이펙트는 텍스트 자체로 구분 (*, !, =, ~ 등)
|
||||
// 전체 색상 변경 제거 - 기본 테마 색상 유지
|
||||
} else {
|
||||
// 기존 레거시 시스템 사용
|
||||
final frameIndex =
|
||||
_currentFrame.clamp(0, _animationData.frames.length - 1);
|
||||
frameText = _animationData.frames[frameIndex];
|
||||
}
|
||||
|
||||
// 테두리 효과 결정 (특수 애니메이션 또는 크리티컬 히트)
|
||||
final isSpecial = _currentSpecialAnimation != null;
|
||||
Border? borderEffect;
|
||||
if (_showCriticalEffect) {
|
||||
// 크리티컬 히트: 노란색 테두리 (Phase 5)
|
||||
borderEffect = Border.all(color: Colors.yellow.withValues(alpha: 0.8), width: 2);
|
||||
borderEffect =
|
||||
Border.all(color: Colors.yellow.withValues(alpha: 0.8), width: 2);
|
||||
} else if (isSpecial) {
|
||||
// 특수 애니메이션: 시안 테두리
|
||||
borderEffect = Border.all(color: AsciiColors.positive.withValues(alpha: 0.5));
|
||||
borderEffect =
|
||||
Border.all(color: AsciiColors.positive.withValues(alpha: 0.5));
|
||||
}
|
||||
|
||||
return Container(
|
||||
@@ -450,51 +379,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: borderEffect,
|
||||
),
|
||||
child: _isBattleMode
|
||||
? LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 60x8 프레임에 맞게 폰트 크기 자동 계산
|
||||
// ASCII 문자 비율: 너비 = 높이 * 0.6 (모노스페이스)
|
||||
final maxWidth = constraints.maxWidth;
|
||||
final maxHeight = constraints.maxHeight;
|
||||
// 60자 폭, 8줄 높이 기준
|
||||
final fontSizeByWidth = maxWidth / 60 / 0.6;
|
||||
final fontSizeByHeight = maxHeight / 8 / 1.2;
|
||||
final fontSize = (fontSizeByWidth < fontSizeByHeight
|
||||
? fontSizeByWidth
|
||||
: fontSizeByHeight)
|
||||
.clamp(6.0, 14.0);
|
||||
|
||||
return Center(
|
||||
child: RichText(
|
||||
text: _buildColoredTextSpan(
|
||||
frameText,
|
||||
TextStyle(
|
||||
fontFamily: 'Courier',
|
||||
fontSize: fontSize,
|
||||
color: textColor,
|
||||
height: 1.2,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Center(
|
||||
child: Text(
|
||||
frameText,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
color: textColor,
|
||||
height: 1.1,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
),
|
||||
child: AsciiCanvasWidget(layers: _composeLayers()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -369,11 +369,14 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
|
||||
// HP 숫자
|
||||
Text(
|
||||
'$current/$max',
|
||||
style: const TextStyle(fontSize: 8, color: Colors.orange),
|
||||
textAlign: TextAlign.right,
|
||||
// HP 숫자 (Flexible로 오버플로우 방지)
|
||||
Flexible(
|
||||
child: Text(
|
||||
'$current/$max',
|
||||
style: const TextStyle(fontSize: 8, color: Colors.orange),
|
||||
textAlign: TextAlign.right,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user