feat(canvas): Canvas 기반 ASCII 애니메이션 렌더러 구현
- JetBrains Mono 폰트 번들링 (Android/iOS 호환성) - Paragraph 캐싱으로 GC 압박 최소화 (최대 256개 캐시) - shouldRepaint layerVersion 기반 최적화 - willChange 동적 설정으로 메모리 절약 - 레이어 기반 합성 구조 (배경/캐릭터/몬스터/이펙트) - hp_mp_bar 몬스터 HP 숫자 오버플로우 수정
This commit is contained in:
@@ -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' / \ ',
|
||||
],
|
||||
];
|
||||
Reference in New Issue
Block a user