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

View File

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