From d23a51466ece18bae65113ccd12274e41e568f73 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Fri, 26 Dec 2025 18:35:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(animation):=20=EA=B3=B5=EA=B2=A9=EC=9E=90?= =?UTF-8?q?=EB=B3=84=20=EC=9C=84=EC=B9=98=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9D=B4=ED=8E=99=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AttackerType enum 추가 (none, player, monster, both) - 플레이어 공격 시에만 캐릭터 이동 (Phase 7) - 몬스터 공격 시에만 몬스터 이동 (Phase 7) - 몬스터 공격 이펙트 추가 (← 방향, Phase 8) - AsciiAnimationCard에서 공격자 타입 전달 --- .../canvas/canvas_battle_composer.dart | 137 ++++++++++++++---- lib/src/core/animation/character_frames.dart | 15 ++ .../game/widgets/ascii_animation_card.dart | 14 ++ 3 files changed, 134 insertions(+), 32 deletions(-) diff --git a/lib/src/core/animation/canvas/canvas_battle_composer.dart b/lib/src/core/animation/canvas/canvas_battle_composer.dart index fda677e..ee00730 100644 --- a/lib/src/core/animation/canvas/canvas_battle_composer.dart +++ b/lib/src/core/animation/canvas/canvas_battle_composer.dart @@ -41,17 +41,19 @@ class CanvasBattleComposer { int subFrame, String? monsterBaseName, EnvironmentType environment, - int globalTick, - ) { + int globalTick, { + AttackerType attacker = AttackerType.none, + }) { final layers = [ _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 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 _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], + _ => [], + }; + } + /// 문자열 스프라이트를 AsciiCell 2D 배열로 변환 List> _spriteToCells(List lines) { return lines.map((line) { @@ -1024,3 +1074,26 @@ List> _largeAlertFrames(MonsterCategory category) { ], }; } + +// ============================================================================ +// 몬스터 공격 이펙트 (← 방향, Phase 8) +// ============================================================================ + +/// 몬스터 공격 준비 프레임 +const _monsterPrepareFrames = >[ + [r' ', r' <', r' '], + [r' ', r' <<', r' '], +]; + +/// 몬스터 공격 프레임 +const _monsterAttackFrames = >[ + [r' ', r' <-- ', r' '], + [r' ', r' <--- ', r' '], + [r' ', r' <-----', r' '], +]; + +/// 몬스터 히트 프레임 +const _monsterHitFrames = >[ + [r' *SLASH*', r' <-----', r' '], + [r'*ATTACK*', r' <----', r' '], +]; diff --git a/lib/src/core/animation/character_frames.dart b/lib/src/core/animation/character_frames.dart index ecfadde..e2cdb00 100644 --- a/lib/src/core/animation/character_frames.dart +++ b/lib/src/core/animation/character_frames.dart @@ -19,6 +19,21 @@ enum BattlePhase { recover, } +/// 공격자 타입 (위치 계산용) +enum AttackerType { + /// 공격 없음 (idle 상태) + none, + + /// 플레이어가 공격 + player, + + /// 몬스터가 공격 + monster, + + /// 동시 공격 (양쪽 모두 이동) + both, +} + /// 캐릭터 프레임 데이터 class CharacterFrame { const CharacterFrame(this.lines); diff --git a/lib/src/features/game/widgets/ascii_animation_card.dart b/lib/src/features/game/widgets/ascii_animation_card.dart index 2c74f85..a2f2d3b 100644 --- a/lib/src/features/game/widgets/ascii_animation_card.dart +++ b/lib/src/features/game/widgets/ascii_animation_card.dart @@ -132,6 +132,9 @@ class _AsciiAnimationCardState extends State { 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 { } 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 { _showSkillEffect = false; // 이벤트 기반 페이즈 종료 _isEventDrivenPhase = false; + // 공격자 타입 리셋 (Phase 7) + _currentAttacker = AttackerType.none; } else { _battleSubFrame++; } @@ -488,6 +501,7 @@ class _AsciiAnimationCardState extends State { widget.monsterBaseName, _environment, _globalTick, + attacker: _currentAttacker, ) ?? [AsciiLayer.empty()], AnimationMode.walking =>