From 687d04974eea6a214953e9f4427c7ed4f5ade3e1 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Tue, 6 Jan 2026 17:55:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(animation):=20=EC=95=84=EB=A0=88=EB=82=98?= =?UTF-8?q?=20=EC=A0=84=ED=88=AC=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CanvasBattleComposer: 아레나 모드 지원 추가 - AsciiAnimationCard: 아레나 전투 애니메이션 렌더링 --- .../canvas/canvas_battle_composer.dart | 104 +++++++++++++++++- .../game/widgets/ascii_animation_card.dart | 14 ++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/lib/src/core/animation/canvas/canvas_battle_composer.dart b/lib/src/core/animation/canvas/canvas_battle_composer.dart index 93847cc..ac8bffb 100644 --- a/lib/src/core/animation/canvas/canvas_battle_composer.dart +++ b/lib/src/core/animation/canvas/canvas_battle_composer.dart @@ -14,6 +14,8 @@ import 'package:asciineverdie/src/core/model/item_stats.dart'; /// /// 기존 BattleComposer의 로직을 레이어 기반으로 변환. /// 출력: `List` (z-order 정렬됨) +/// +/// PvP 모드: [opponentRaceId]가 설정되면 몬스터 대신 상대 캐릭터(좌우 반전) 표시 class CanvasBattleComposer { const CanvasBattleComposer({ required this.weaponCategory, @@ -22,6 +24,8 @@ class CanvasBattleComposer { required this.monsterSize, this.raceId, this.weaponRarity, + this.opponentRaceId, + this.opponentHasShield = false, }); final WeaponCategory weaponCategory; @@ -35,6 +39,15 @@ class CanvasBattleComposer { /// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상) final ItemRarity? weaponRarity; + /// 상대 종족 ID (PvP 모드: 설정 시 몬스터 대신 캐릭터 표시) + final String? opponentRaceId; + + /// 상대 방패 장착 여부 (PvP 모드) + final bool opponentHasShield; + + /// PvP 모드 여부 + bool get isPvP => opponentRaceId != null; + /// 프레임 상수 static const int frameWidth = 60; static const int frameHeight = 8; @@ -59,7 +72,11 @@ class CanvasBattleComposer { final layers = [ _createBackgroundLayer(environment, globalTick), _createCharacterLayer(phase, subFrame, attacker), - _createMonsterLayer(phase, subFrame, attacker), + // PvP 모드: 몬스터 대신 상대 캐릭터(좌우 반전) 표시 + if (isPvP) + _createOpponentCharacterLayer(phase, subFrame, attacker) + else + _createMonsterLayer(phase, subFrame, attacker), ]; // 이펙트 레이어 (준비/공격/히트 페이즈에서, 공격자 있을 때) @@ -246,6 +263,91 @@ class CanvasBattleComposer { ); } + /// 상대 캐릭터 레이어 생성 (PvP 모드, z=1) + /// + /// 몬스터 대신 상대 캐릭터를 좌우 반전하여 표시 + AsciiLayer _createOpponentCharacterLayer( + BattlePhase phase, + int subFrame, + AttackerType attacker, + ) { + // 상대 종족별 프레임 조회 + CharacterFrame opponentFrame; + if (opponentRaceId != null && opponentRaceId!.isNotEmpty) { + final raceData = RaceCharacterFrames.get(opponentRaceId!); + if (raceData != null) { + final frames = raceData.getFrames(phase); + opponentFrame = frames[subFrame % frames.length]; + } else { + opponentFrame = getCharacterFrame(phase, subFrame); + } + } else { + opponentFrame = getCharacterFrame(phase, subFrame); + } + + if (opponentHasShield) { + opponentFrame = opponentFrame.withShield(); + } + + // 좌우 반전 + final mirroredLines = _mirrorLines(opponentFrame.lines); + + // 상대가 공격자인지 확인 (몬스터 역할) + final isOpponentAttacking = + attacker == AttackerType.monster || attacker == AttackerType.both; + + // 페이즈별 X 위치 (몬스터와 동일하지만 캐릭터 너비 기준) + const opponentWidth = 6; + final opponentRightEdge = switch (phase) { + BattlePhase.idle => 48, + BattlePhase.prepare => isOpponentAttacking ? 45 : 48, + BattlePhase.attack => isOpponentAttacking ? 42 : 48, + BattlePhase.hit => isOpponentAttacking ? 42 : 48, + BattlePhase.recover => isOpponentAttacking ? 45 : 48, + }; + final opponentX = opponentRightEdge - opponentWidth; + + final cells = _spriteToCells(mirroredLines); + final opponentY = frameHeight - cells.length - 1; + + return AsciiLayer( + cells: cells, + zIndex: 1, + offsetX: opponentX, + offsetY: opponentY, + ); + } + + /// 문자열 좌우 반전 (PvP 모드용) + List _mirrorLines(List lines) { + return lines.map((line) { + final chars = line.split(''); + final mirrored = chars.reversed.map(_mirrorChar).toList(); + return mirrored.join(); + }).toList(); + } + + /// 개별 문자 미러링 + String _mirrorChar(String char) { + return switch (char) { + '/' => r'\', + r'\' => '/', + '(' => ')', + ')' => '(', + '[' => ']', + ']' => '[', + '{' => '}', + '}' => '{', + '<' => '>', + '>' => '<', + '┘' => '└', + '└' => '┘', + '┐' => '┌', + '┌' => '┐', + _ => char, + }; + } + /// 이펙트 레이어 생성 (z=3, 캐릭터/몬스터 위에 표시) /// /// Phase 8: 공격자에 따라 이펙트 위치/모양 분리 diff --git a/lib/src/features/game/widgets/ascii_animation_card.dart b/lib/src/features/game/widgets/ascii_animation_card.dart index 025495f..646ecfa 100644 --- a/lib/src/features/game/widgets/ascii_animation_card.dart +++ b/lib/src/features/game/widgets/ascii_animation_card.dart @@ -50,6 +50,8 @@ class AsciiAnimationCard extends StatefulWidget { this.latestCombatEvent, this.raceId, this.weaponRarity, + this.opponentRaceId, + this.opponentHasShield = false, }); final TaskType taskType; @@ -89,6 +91,12 @@ class AsciiAnimationCard extends StatefulWidget { /// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상) final ItemRarity? weaponRarity; + /// 상대 종족 ID (PvP 모드: 설정 시 몬스터 대신 캐릭터 표시) + final String? opponentRaceId; + + /// 상대 방패 장착 여부 (PvP 모드) + final bool opponentHasShield; + @override State createState() => _AsciiAnimationCardState(); } @@ -208,7 +216,9 @@ class _AsciiAnimationCardState extends State { oldWidget.shieldName != widget.shieldName || oldWidget.monsterLevel != widget.monsterLevel || oldWidget.raceId != widget.raceId || - oldWidget.weaponRarity != widget.weaponRarity) { + oldWidget.weaponRarity != widget.weaponRarity || + oldWidget.opponentRaceId != widget.opponentRaceId || + oldWidget.opponentHasShield != widget.opponentHasShield) { _updateAnimation(); } } @@ -445,6 +455,8 @@ class _AsciiAnimationCardState extends State { monsterSize: monsterSize, raceId: widget.raceId, weaponRarity: widget.weaponRarity, + opponentRaceId: widget.opponentRaceId, + opponentHasShield: widget.opponentHasShield, ); // 환경 타입 추론