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

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