feat(animation): 아레나 전투 애니메이션 지원

- CanvasBattleComposer: 아레나 모드 지원 추가
- AsciiAnimationCard: 아레나 전투 애니메이션 렌더링
This commit is contained in:
JiWoong Sul
2026-01-06 17:55:07 +09:00
parent a2e93efc97
commit 687d04974e
2 changed files with 116 additions and 2 deletions

View File

@@ -14,6 +14,8 @@ import 'package:asciineverdie/src/core/model/item_stats.dart';
///
/// 기존 BattleComposer의 로직을 레이어 기반으로 변환.
/// 출력: `List<AsciiLayer>` (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 = <AsciiLayer>[
_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<String> _mirrorLines(List<String> 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: 공격자에 따라 이펙트 위치/모양 분리

View File

@@ -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<AsciiAnimationCard> createState() => _AsciiAnimationCardState();
}
@@ -208,7 +216,9 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
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<AsciiAnimationCard> {
monsterSize: monsterSize,
raceId: widget.raceId,
weaponRarity: widget.weaponRarity,
opponentRaceId: widget.opponentRaceId,
opponentHasShield: widget.opponentHasShield,
);
// 환경 타입 추론