feat(animation): 전투 애니메이션 및 캔버스 개선

- CanvasBattleComposer 몬스터 등급별 색상 지원
- AsciiCanvasPainter/Widget 개선
- AsciiCell 스타일 확장
This commit is contained in:
JiWoong Sul
2026-01-05 17:52:51 +09:00
parent 5c8ab0d3f4
commit 4688aff56b
4 changed files with 282 additions and 3 deletions

View File

@@ -52,6 +52,10 @@ class AsciiCanvasPainter extends CustomPainter {
this.objectColor = AsciiColors.object, this.objectColor = AsciiColors.object,
this.positiveColor = AsciiColors.positive, this.positiveColor = AsciiColors.positive,
this.negativeColor = AsciiColors.negative, this.negativeColor = AsciiColors.negative,
this.rarityUncommonColor = AsciiColors.rarityUncommon,
this.rarityRareColor = AsciiColors.rarityRare,
this.rarityEpicColor = AsciiColors.rarityEpic,
this.rarityLegendaryColor = AsciiColors.rarityLegendary,
}); });
/// 렌더링할 레이어 목록 (z-order 정렬 필요) /// 렌더링할 레이어 목록 (z-order 정렬 필요)
@@ -81,6 +85,22 @@ class AsciiCanvasPainter extends CustomPainter {
/// 네거티브 이펙트 색상 (테마 인식) /// 네거티브 이펙트 색상 (테마 인식)
final Color negativeColor; final Color negativeColor;
// ═══════════════════════════════════════════════════════════════════════
// 무기 등급(ItemRarity) 색상 (Phase 9)
// ═══════════════════════════════════════════════════════════════════════
/// Uncommon 등급 색상 (초록)
final Color rarityUncommonColor;
/// Rare 등급 색상 (파랑)
final Color rarityRareColor;
/// Epic 등급 색상 (보라)
final Color rarityEpicColor;
/// Legendary 등급 색상 (금색)
final Color rarityLegendaryColor;
/// Paragraph 캐시 (문자+색상+크기 조합별) /// Paragraph 캐시 (문자+색상+크기 조합별)
static final Map<_ParagraphCacheKey, ui.Paragraph> _paragraphCache = {}; static final Map<_ParagraphCacheKey, ui.Paragraph> _paragraphCache = {};
@@ -209,6 +229,11 @@ class AsciiCanvasPainter extends CustomPainter {
AsciiCellColor.object => objectColor, AsciiCellColor.object => objectColor,
AsciiCellColor.positive => positiveColor, AsciiCellColor.positive => positiveColor,
AsciiCellColor.negative => negativeColor, AsciiCellColor.negative => negativeColor,
// 무기 등급 색상 (Phase 9)
AsciiCellColor.rarityUncommon => rarityUncommonColor,
AsciiCellColor.rarityRare => rarityRareColor,
AsciiCellColor.rarityEpic => rarityEpicColor,
AsciiCellColor.rarityLegendary => rarityLegendaryColor,
}; };
} }

View File

@@ -46,6 +46,12 @@ class AsciiCanvasWidget extends StatelessWidget {
final posColor = AsciiColors.positiveOf(context); final posColor = AsciiColors.positiveOf(context);
final negColor = AsciiColors.negativeOf(context); final negColor = AsciiColors.negativeOf(context);
// 무기 등급 색상 (Phase 9)
final uncommonColor = AsciiColors.rarityUncommonOf(context);
final rareColor = AsciiColors.rarityRareOf(context);
final epicColor = AsciiColors.rarityEpicOf(context);
final legendaryColor = AsciiColors.rarityLegendaryOf(context);
return RepaintBoundary( return RepaintBoundary(
child: CustomPaint( child: CustomPaint(
painter: AsciiCanvasPainter( painter: AsciiCanvasPainter(
@@ -58,6 +64,10 @@ class AsciiCanvasWidget extends StatelessWidget {
objectColor: objColor, objectColor: objColor,
positiveColor: posColor, positiveColor: posColor,
negativeColor: negColor, negativeColor: negColor,
rarityUncommonColor: uncommonColor,
rarityRareColor: rareColor,
rarityEpicColor: epicColor,
rarityLegendaryColor: legendaryColor,
), ),
size: Size.infinite, size: Size.infinite,
isComplex: true, isComplex: true,

View File

@@ -1,4 +1,4 @@
/// ASCII 셀 색상 (4색 팔레트) /// ASCII 셀 색상 (4색 팔레트 + 무기 등급 색상)
enum AsciiCellColor { enum AsciiCellColor {
/// 배경색 (검정) /// 배경색 (검정)
background, background,
@@ -6,11 +6,27 @@ enum AsciiCellColor {
/// 오브젝트 (흰색) - 캐릭터, 몬스터, 지형 /// 오브젝트 (흰색) - 캐릭터, 몬스터, 지형
object, object,
/// 포지티브 이펙트 (시안) - !, +, =, >, < /// 포지티브 이펙트 (시안) - !, +, =, >, < / common 무기
positive, positive,
/// 네거티브 이펙트 (마젠타) - *, ~ /// 네거티브 이펙트 (마젠타) - *, ~
negative, negative,
// ═══════════════════════════════════════════════════════════════════════
// 무기 등급(ItemRarity) 색상 (Phase 9)
// ═══════════════════════════════════════════════════════════════════════
/// Uncommon 등급 (초록)
rarityUncommon,
/// Rare 등급 (파랑)
rarityRare,
/// Epic 등급 (보라)
rarityEpic,
/// Legendary 등급 (금색)
rarityLegendary,
} }
/// 단일 ASCII 셀 데이터 /// 단일 ASCII 셀 데이터

View File

@@ -8,6 +8,7 @@ import 'package:asciineverdie/src/core/animation/monster_size.dart';
import 'package:asciineverdie/src/core/animation/race_character_frames.dart'; import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
import 'package:asciineverdie/src/core/animation/weapon_category.dart'; import 'package:asciineverdie/src/core/animation/weapon_category.dart';
import 'package:asciineverdie/src/core/animation/weapon_effects.dart'; import 'package:asciineverdie/src/core/animation/weapon_effects.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
/// Canvas용 전투 프레임 합성기 /// Canvas용 전투 프레임 합성기
/// ///
@@ -20,6 +21,7 @@ class CanvasBattleComposer {
required this.monsterCategory, required this.monsterCategory,
required this.monsterSize, required this.monsterSize,
this.raceId, this.raceId,
this.weaponRarity,
}); });
final WeaponCategory weaponCategory; final WeaponCategory weaponCategory;
@@ -30,6 +32,9 @@ class CanvasBattleComposer {
/// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션) /// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션)
final String? raceId; final String? raceId;
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
final ItemRarity? weaponRarity;
/// 프레임 상수 /// 프레임 상수
static const int frameWidth = 60; static const int frameWidth = 60;
static const int frameHeight = 8; static const int frameHeight = 8;
@@ -43,6 +48,13 @@ class CanvasBattleComposer {
EnvironmentType environment, EnvironmentType environment,
int globalTick, { int globalTick, {
AttackerType attacker = AttackerType.none, AttackerType attacker = AttackerType.none,
bool isCritical = false,
bool isEvade = false,
bool isMiss = false,
bool isDebuff = false,
bool isDot = false,
bool isBlock = false,
bool isParry = false,
}) { }) {
final layers = <AsciiLayer>[ final layers = <AsciiLayer>[
_createBackgroundLayer(environment, globalTick), _createBackgroundLayer(environment, globalTick),
@@ -61,6 +73,36 @@ class CanvasBattleComposer {
} }
} }
// 텍스트 이펙트 레이어 (Phase 10~11)
// 크리티컬 텍스트 (상단 중앙)
if (isCritical && (phase == BattlePhase.attack || phase == BattlePhase.hit)) {
layers.add(_createCriticalTextLayer(subFrame));
}
// 회피 텍스트 (캐릭터 위)
if (isEvade) {
layers.add(_createEvadeTextLayer(subFrame));
}
// 미스 텍스트 (몬스터 위)
if (isMiss) {
layers.add(_createMissTextLayer(subFrame));
}
// 디버프 텍스트 (몬스터 위)
if (isDebuff) {
layers.add(_createDebuffTextLayer(subFrame));
}
// DOT 텍스트 (몬스터 위)
if (isDot) {
layers.add(_createDotTextLayer(subFrame));
}
// 블록 텍스트 (캐릭터 위)
if (isBlock) {
layers.add(_createBlockTextLayer(subFrame));
}
// 패리 텍스트 (캐릭터 위)
if (isParry) {
layers.add(_createParryTextLayer(subFrame));
}
// z-order 정렬 // z-order 정렬
layers.sort((a, b) => a.zIndex.compareTo(b.zIndex)); layers.sort((a, b) => a.zIndex.compareTo(b.zIndex));
@@ -262,7 +304,15 @@ class CanvasBattleComposer {
if (effectLines.isEmpty) return null; if (effectLines.isEmpty) return null;
final cells = _spriteToCells(effectLines); // Phase 9: 플레이어 공격 시 무기 등급 색상 적용
final List<List<AsciiCell>> cells;
if (attacker == AttackerType.player && weaponRarity != null) {
// 무기 등급에 따른 이펙트 색상
cells = _spriteToCellsWithColor(effectLines, weaponRarity!.effectCellColor);
} else {
// 기본 색상 (자동 색상 결정)
cells = _spriteToCells(effectLines);
}
// 이펙트 높이에 따른 동적 Y 위치 (캔버스 하단 기준) // 이펙트 높이에 따른 동적 Y 위치 (캔버스 하단 기준)
final effectHeight = effectLines.length; final effectHeight = effectLines.length;
@@ -276,6 +326,119 @@ class CanvasBattleComposer {
); );
} }
/// 크리티컬 텍스트 레이어 생성 (z=4, 최상위)
///
/// 크리티컬 히트 시 "*CRIT!*" 텍스트를 화면 상단 중앙에 표시.
/// 노란색(positive) 색상으로 강조.
AsciiLayer _createCriticalTextLayer(int subFrame) {
// 프레임별 텍스트 애니메이션 (반짝임 효과)
final textLines = subFrame % 2 == 0
? _critTextFrames[0]
: _critTextFrames[1];
final cells = textLines.map((String line) {
return line.split('').map((String char) {
if (char == ' ') return AsciiCell.empty;
return AsciiCell(char: char, color: AsciiCellColor.positive);
}).toList();
}).toList();
// 화면 상단 중앙 배치 (Y=0~1, X=중앙)
final textWidth = textLines.isNotEmpty ? textLines[0].length : 0;
final offsetX = (frameWidth - textWidth) ~/ 2;
return AsciiLayer(
cells: cells,
zIndex: 4, // 이펙트(z=3) 위에 표시
offsetX: offsetX,
offsetY: 0,
);
}
/// 회피 텍스트 레이어 생성 (캐릭터 위, positive)
AsciiLayer _createEvadeTextLayer(int subFrame) {
return _createTextLayer(
frames: _evadeTextFrames,
subFrame: subFrame,
color: AsciiCellColor.positive,
offsetX: 15, // 캐릭터 위
);
}
/// 미스 텍스트 레이어 생성 (몬스터 위, negative)
AsciiLayer _createMissTextLayer(int subFrame) {
return _createTextLayer(
frames: _missTextFrames,
subFrame: subFrame,
color: AsciiCellColor.negative,
offsetX: 35, // 몬스터 위
);
}
/// 디버프 텍스트 레이어 생성 (몬스터 위, negative)
AsciiLayer _createDebuffTextLayer(int subFrame) {
return _createTextLayer(
frames: _debuffTextFrames,
subFrame: subFrame,
color: AsciiCellColor.negative,
offsetX: 35, // 몬스터 위
);
}
/// DOT 텍스트 레이어 생성 (몬스터 위, negative)
AsciiLayer _createDotTextLayer(int subFrame) {
return _createTextLayer(
frames: _dotTextFrames,
subFrame: subFrame,
color: AsciiCellColor.negative,
offsetX: 35, // 몬스터 위
);
}
/// 블록 텍스트 레이어 생성 (캐릭터 위, positive)
AsciiLayer _createBlockTextLayer(int subFrame) {
return _createTextLayer(
frames: _blockTextFrames,
subFrame: subFrame,
color: AsciiCellColor.positive,
offsetX: 15, // 캐릭터 위
);
}
/// 패리 텍스트 레이어 생성 (캐릭터 위, positive)
AsciiLayer _createParryTextLayer(int subFrame) {
return _createTextLayer(
frames: _parryTextFrames,
subFrame: subFrame,
color: AsciiCellColor.positive,
offsetX: 15, // 캐릭터 위
);
}
/// 공통 텍스트 레이어 생성 헬퍼
AsciiLayer _createTextLayer({
required List<List<String>> frames,
required int subFrame,
required AsciiCellColor color,
required int offsetX,
}) {
final textLines = frames[subFrame % 2];
final cells = textLines.map((String line) {
return line.split('').map((String char) {
if (char == ' ') return AsciiCell.empty;
return AsciiCell(char: char, color: color);
}).toList();
}).toList();
return AsciiLayer(
cells: cells,
zIndex: 4,
offsetX: offsetX,
offsetY: 0,
);
}
/// 몬스터 공격 이펙트 (← 방향) /// 몬스터 공격 이펙트 (← 방향)
List<String> _getMonsterAttackEffect(BattlePhase phase, int subFrame) { List<String> _getMonsterAttackEffect(BattlePhase phase, int subFrame) {
return switch (phase) { return switch (phase) {
@@ -293,6 +456,21 @@ class CanvasBattleComposer {
}).toList(); }).toList();
} }
/// 문자열 스프라이트를 지정된 색상으로 AsciiCell 2D 배열로 변환 (Phase 9)
///
/// 이펙트 문자(공백 아닌 문자)에 지정 색상 적용
List<List<AsciiCell>> _spriteToCellsWithColor(
List<String> lines,
AsciiCellColor effectColor,
) {
return lines.map((line) {
return line.split('').map((char) {
if (char == ' ' || char.isEmpty) return AsciiCell.empty;
return AsciiCell(char: char, color: effectColor);
}).toList();
}).toList();
}
/// 문자열 스프라이트를 오른쪽 정렬된 AsciiCell 2D 배열로 변환 /// 문자열 스프라이트를 오른쪽 정렬된 AsciiCell 2D 배열로 변환
List<List<AsciiCell>> _spriteToRightAlignedCells( List<List<AsciiCell>> _spriteToRightAlignedCells(
List<String> lines, List<String> lines,
@@ -1129,3 +1307,53 @@ const _monsterHitFrames = <List<String>>[
[r' *SLASH!* ', r' **** ', r' <----- ', r' **** ', r' '], [r' *SLASH!* ', r' **** ', r' <----- ', r' **** ', r' '],
[r'*ATTACK!* ', r' **** ', r' <---- ', r' **** ', r' '], [r'*ATTACK!* ', r' **** ', r' <---- ', r' **** ', r' '],
]; ];
// ============================================================================
// 크리티컬 텍스트 프레임 (2줄, Phase 10)
// ============================================================================
/// 크리티컬 히트 텍스트 프레임 (반짝임 애니메이션)
const _critTextFrames = <List<String>>[
[r'*CRITICAL!*', r' ========='],
[r'=CRITICAL!=', r' *********'],
];
// ============================================================================
// 전투 텍스트 이펙트 프레임 (Phase 11)
// ============================================================================
/// 회피 텍스트 프레임 (플레이어 회피 성공)
const _evadeTextFrames = <List<String>>[
[r'*EVADE!*', r'========'],
[r'=EVADE!=', r'********'],
];
/// 미스 텍스트 프레임 (플레이어 공격 빗나감)
const _missTextFrames = <List<String>>[
[r'*MISS!*', r'======='],
[r'=MISS!=', r'*******'],
];
/// 디버프 텍스트 프레임 (적에게 디버프 적용)
const _debuffTextFrames = <List<String>>[
[r'*DEBUFF!*', r'========='],
[r'=DEBUFF!=', r'*********'],
];
/// DOT 텍스트 프레임 (지속 피해)
const _dotTextFrames = <List<String>>[
[r'*DOT!*', r'======'],
[r'=DOT!=', r'******'],
];
/// 블록 텍스트 프레임 (방패 방어)
const _blockTextFrames = <List<String>>[
[r'*BLOCK!*', r'========'],
[r'=BLOCK!=', r'********'],
];
/// 패리 텍스트 프레임 (무기 쳐내기)
const _parryTextFrames = <List<String>>[
[r'*PARRY!*', r'========'],
[r'=PARRY!=', r'********'],
];