feat(animation): 공격자별 위치 분리 및 이펙트 추가

- AttackerType enum 추가 (none, player, monster, both)
- 플레이어 공격 시에만 캐릭터 이동 (Phase 7)
- 몬스터 공격 시에만 몬스터 이동 (Phase 7)
- 몬스터 공격 이펙트 추가 (← 방향, Phase 8)
- AsciiAnimationCard에서 공격자 타입 전달
This commit is contained in:
JiWoong Sul
2025-12-26 18:35:43 +09:00
parent 6e56420a07
commit d23a51466e
3 changed files with 134 additions and 32 deletions

View File

@@ -41,17 +41,19 @@ class CanvasBattleComposer {
int subFrame,
String? monsterBaseName,
EnvironmentType environment,
int globalTick,
) {
int globalTick, {
AttackerType attacker = AttackerType.none,
}) {
final layers = <AsciiLayer>[
_createBackgroundLayer(environment, globalTick),
_createCharacterLayer(phase, subFrame),
_createMonsterLayer(phase, subFrame),
_createCharacterLayer(phase, subFrame, attacker),
_createMonsterLayer(phase, subFrame, attacker),
];
// 이펙트 레이어 (공격/히트 페이즈에서만)
if (phase == BattlePhase.attack || phase == BattlePhase.hit) {
final effectLayer = _createEffectLayer(phase, subFrame);
// 이펙트 레이어 (공격/히트 페이즈에서만, 공격자 있을 때)
if ((phase == BattlePhase.attack || phase == BattlePhase.hit) &&
attacker != AttackerType.none) {
final effectLayer = _createEffectLayer(phase, subFrame, attacker);
if (effectLayer != null) {
layers.add(effectLayer);
}
@@ -101,7 +103,12 @@ class CanvasBattleComposer {
/// 캐릭터 레이어 생성 (z=1)
///
/// Phase 4: 종족별 캐릭터 프레임 지원
AsciiLayer _createCharacterLayer(BattlePhase phase, int subFrame) {
/// Phase 7: 공격자별 위치 분리 (플레이어가 공격자일 때만 이동)
AsciiLayer _createCharacterLayer(
BattlePhase phase,
int subFrame,
AttackerType attacker,
) {
CharacterFrame charFrame;
// 종족 ID가 있으면 종족별 프레임 사용
@@ -122,13 +129,18 @@ class CanvasBattleComposer {
charFrame = charFrame.withShield();
}
// 페이즈별 X 위치 (idle: 20%, attack: 30%)
// 플레이어가 공격자인지 확인
final isPlayerAttacking =
attacker == AttackerType.player || attacker == AttackerType.both;
// 페이즈별 X 위치 (Phase 7: 공격자별 위치 분리)
// 플레이어가 공격자일 때만 이동, 아니면 제자리(12)
final charX = switch (phase) {
BattlePhase.idle => 12, // 20%
BattlePhase.prepare => 15, // 전환중
BattlePhase.attack => 18, // 30%
BattlePhase.hit => 18, // 30%
BattlePhase.recover => 15, // 전환중
BattlePhase.idle => 12,
BattlePhase.prepare => isPlayerAttacking ? 15 : 12,
BattlePhase.attack => isPlayerAttacking ? 18 : 12,
BattlePhase.hit => attacker == AttackerType.both ? 18 : 12,
BattlePhase.recover => isPlayerAttacking ? 15 : 12,
};
final cells = _spriteToCells(charFrame.lines);
@@ -144,7 +156,13 @@ class CanvasBattleComposer {
}
/// 몬스터 레이어 생성 (z=1, 캐릭터보다 뒤)
AsciiLayer _createMonsterLayer(BattlePhase phase, int subFrame) {
///
/// Phase 7: 공격자별 위치 분리 (몬스터가 공격자일 때만 이동)
AsciiLayer _createMonsterLayer(
BattlePhase phase,
int subFrame,
AttackerType attacker,
) {
final monsterFrames = _getAnimatedMonsterFrames(
monsterCategory,
monsterSize,
@@ -155,14 +173,18 @@ class CanvasBattleComposer {
// 몬스터 스프라이트를 오른쪽 정렬하여 셀로 변환
final cells = _spriteToRightAlignedCells(monsterFrame, monsterWidth);
// 페이즈별 X 위치 (idle: 80%, attack: 70%)
// 몬스터 오른쪽 끝 기준: idle=48, attack=42
// 몬스터가 공격자인지 확인
final isMonsterAttacking =
attacker == AttackerType.monster || attacker == AttackerType.both;
// 페이즈별 X 위치 (Phase 7: 공격자별 위치 분리)
// 몬스터가 공격자일 때만 이동, 아니면 제자리(48)
final monsterRightEdge = switch (phase) {
BattlePhase.idle => 48, // 80%
BattlePhase.prepare => 45, // 전환중
BattlePhase.attack => 42, // 70%
BattlePhase.hit => 42, // 70%
BattlePhase.recover => 45, // 전환중
BattlePhase.idle => 48,
BattlePhase.prepare => isMonsterAttacking ? 45 : 48,
BattlePhase.attack => attacker == AttackerType.both ? 42 : 48,
BattlePhase.hit => isMonsterAttacking ? 42 : 48,
BattlePhase.recover => isMonsterAttacking ? 45 : 48,
};
final monsterX = monsterRightEdge - monsterWidth;
@@ -178,21 +200,39 @@ class CanvasBattleComposer {
}
/// 이펙트 레이어 생성 (z=3, 캐릭터/몬스터 위에 표시)
AsciiLayer? _createEffectLayer(BattlePhase phase, int subFrame) {
final effect = getWeaponEffect(weaponCategory);
final effectLines = _getEffectLines(effect, phase, subFrame);
///
/// Phase 8: 공격자에 따라 이펙트 위치/모양 분리
/// - 플레이어 공격: 몬스터 왼쪽에 무기 이펙트 (→ 방향)
/// - 몬스터 공격: 캐릭터 오른쪽에 공격 이펙트 (← 방향)
AsciiLayer? _createEffectLayer(
BattlePhase phase,
int subFrame,
AttackerType attacker,
) {
// 공격자에 따라 다른 이펙트 사용
final List<String> effectLines;
final int effectX;
if (attacker == AttackerType.player) {
// 플레이어 공격: 무기 이펙트 → 몬스터 왼쪽
final effect = getWeaponEffect(weaponCategory);
effectLines = _getEffectLines(effect, phase, subFrame);
effectX = 28;
} else if (attacker == AttackerType.monster) {
// 몬스터 공격: 왼쪽 방향 이펙트 → 캐릭터 오른쪽
effectLines = _getMonsterAttackEffect(phase, subFrame);
effectX = 18;
} else {
// 동시 공격(both): 무기 이펙트 중앙 표시
final effect = getWeaponEffect(weaponCategory);
effectLines = _getEffectLines(effect, phase, subFrame);
effectX = 23;
}
if (effectLines.isEmpty) return null;
final cells = _spriteToCells(effectLines);
// 이펙트 위치: 캐릭터 오른쪽 (30% 위치 + 캐릭터 너비)
final charX = switch (phase) {
BattlePhase.attack => 18, // 30%
BattlePhase.hit => 18, // 30%
_ => 12,
};
final effectX = charX + 6; // 캐릭터 너비만큼 오른쪽
// 캐릭터 3줄 기준, 머리 위치
final effectY = frameHeight - 3 - 1;
@@ -204,6 +244,16 @@ class CanvasBattleComposer {
);
}
/// 몬스터 공격 이펙트 (← 방향)
List<String> _getMonsterAttackEffect(BattlePhase phase, int subFrame) {
return switch (phase) {
BattlePhase.prepare => _monsterPrepareFrames[subFrame % _monsterPrepareFrames.length],
BattlePhase.attack => _monsterAttackFrames[subFrame % _monsterAttackFrames.length],
BattlePhase.hit => _monsterHitFrames[subFrame % _monsterHitFrames.length],
_ => <String>[],
};
}
/// 문자열 스프라이트를 AsciiCell 2D 배열로 변환
List<List<AsciiCell>> _spriteToCells(List<String> lines) {
return lines.map((line) {
@@ -1024,3 +1074,26 @@ List<List<String>> _largeAlertFrames(MonsterCategory category) {
],
};
}
// ============================================================================
// 몬스터 공격 이펙트 (← 방향, Phase 8)
// ============================================================================
/// 몬스터 공격 준비 프레임
const _monsterPrepareFrames = <List<String>>[
[r' ', r' <', r' '],
[r' ', r' <<', r' '],
];
/// 몬스터 공격 프레임
const _monsterAttackFrames = <List<String>>[
[r' ', r' <-- ', r' '],
[r' ', r' <--- ', r' '],
[r' ', r' <-----', r' '],
];
/// 몬스터 히트 프레임
const _monsterHitFrames = <List<String>>[
[r' *SLASH*', r' <-----', r' '],
[r'*ATTACK*', r' <----', r' '],
];

View File

@@ -19,6 +19,21 @@ enum BattlePhase {
recover,
}
/// 공격자 타입 (위치 계산용)
enum AttackerType {
/// 공격 없음 (idle 상태)
none,
/// 플레이어가 공격
player,
/// 몬스터가 공격
monster,
/// 동시 공격 (양쪽 모두 이동)
both,
}
/// 캐릭터 프레임 데이터
class CharacterFrame {
const CharacterFrame(this.lines);

View File

@@ -132,6 +132,9 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
int _eventDrivenPhaseFrames = 0;
bool _isEventDrivenPhase = false;
// 공격자 타입 (Phase 7: 공격자별 위치 분리)
AttackerType _currentAttacker = AttackerType.none;
// 특수 애니메이션 프레임 수는 ascii_animation_type.dart의
// specialAnimationFrameCounts 상수 사용
@@ -331,6 +334,14 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
} else {
_isEventDrivenPhase = false;
}
// 공격자 타입 결정 (Phase 7: 공격자별 위치 분리)
_currentAttacker = switch (event.type) {
CombatEventType.playerAttack ||
CombatEventType.playerSkill => AttackerType.player,
CombatEventType.monsterAttack => AttackerType.monster,
_ => AttackerType.none,
};
});
}
@@ -465,6 +476,8 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_showSkillEffect = false;
// 이벤트 기반 페이즈 종료
_isEventDrivenPhase = false;
// 공격자 타입 리셋 (Phase 7)
_currentAttacker = AttackerType.none;
} else {
_battleSubFrame++;
}
@@ -488,6 +501,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
widget.monsterBaseName,
_environment,
_globalTick,
attacker: _currentAttacker,
) ??
[AsciiLayer.empty()],
AnimationMode.walking =>