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

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

Binary file not shown.

View File

@@ -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,
};
}

View File

@@ -19,6 +19,9 @@ enum AsciiAnimationType {
/// Act 완료 (플롯 진행)
actComplete,
/// 부활 (사망 후)
resurrection,
}
/// TaskType을 AsciiAnimationType으로 변환

View 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;
}
}

View 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,
),
);
}
}

View 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;
}

View 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,
);
}
}

File diff suppressed because it is too large Load Diff

View 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'___/ \___',
],
];

View 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' / \ ',
],
];

View 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' / \ ',
],
];

View File

@@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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