feat(animation): 아레나 전투 애니메이션 지원
- CanvasBattleComposer: 아레나 모드 지원 추가 - AsciiAnimationCard: 아레나 전투 애니메이션 렌더링
This commit is contained in:
@@ -14,6 +14,8 @@ import 'package:asciineverdie/src/core/model/item_stats.dart';
|
|||||||
///
|
///
|
||||||
/// 기존 BattleComposer의 로직을 레이어 기반으로 변환.
|
/// 기존 BattleComposer의 로직을 레이어 기반으로 변환.
|
||||||
/// 출력: `List<AsciiLayer>` (z-order 정렬됨)
|
/// 출력: `List<AsciiLayer>` (z-order 정렬됨)
|
||||||
|
///
|
||||||
|
/// PvP 모드: [opponentRaceId]가 설정되면 몬스터 대신 상대 캐릭터(좌우 반전) 표시
|
||||||
class CanvasBattleComposer {
|
class CanvasBattleComposer {
|
||||||
const CanvasBattleComposer({
|
const CanvasBattleComposer({
|
||||||
required this.weaponCategory,
|
required this.weaponCategory,
|
||||||
@@ -22,6 +24,8 @@ class CanvasBattleComposer {
|
|||||||
required this.monsterSize,
|
required this.monsterSize,
|
||||||
this.raceId,
|
this.raceId,
|
||||||
this.weaponRarity,
|
this.weaponRarity,
|
||||||
|
this.opponentRaceId,
|
||||||
|
this.opponentHasShield = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final WeaponCategory weaponCategory;
|
final WeaponCategory weaponCategory;
|
||||||
@@ -35,6 +39,15 @@ class CanvasBattleComposer {
|
|||||||
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
|
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
|
||||||
final ItemRarity? weaponRarity;
|
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 frameWidth = 60;
|
||||||
static const int frameHeight = 8;
|
static const int frameHeight = 8;
|
||||||
@@ -59,6 +72,10 @@ class CanvasBattleComposer {
|
|||||||
final layers = <AsciiLayer>[
|
final layers = <AsciiLayer>[
|
||||||
_createBackgroundLayer(environment, globalTick),
|
_createBackgroundLayer(environment, globalTick),
|
||||||
_createCharacterLayer(phase, subFrame, attacker),
|
_createCharacterLayer(phase, subFrame, attacker),
|
||||||
|
// PvP 모드: 몬스터 대신 상대 캐릭터(좌우 반전) 표시
|
||||||
|
if (isPvP)
|
||||||
|
_createOpponentCharacterLayer(phase, subFrame, attacker)
|
||||||
|
else
|
||||||
_createMonsterLayer(phase, subFrame, attacker),
|
_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, 캐릭터/몬스터 위에 표시)
|
/// 이펙트 레이어 생성 (z=3, 캐릭터/몬스터 위에 표시)
|
||||||
///
|
///
|
||||||
/// Phase 8: 공격자에 따라 이펙트 위치/모양 분리
|
/// Phase 8: 공격자에 따라 이펙트 위치/모양 분리
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ class AsciiAnimationCard extends StatefulWidget {
|
|||||||
this.latestCombatEvent,
|
this.latestCombatEvent,
|
||||||
this.raceId,
|
this.raceId,
|
||||||
this.weaponRarity,
|
this.weaponRarity,
|
||||||
|
this.opponentRaceId,
|
||||||
|
this.opponentHasShield = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TaskType taskType;
|
final TaskType taskType;
|
||||||
@@ -89,6 +91,12 @@ class AsciiAnimationCard extends StatefulWidget {
|
|||||||
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
|
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
|
||||||
final ItemRarity? weaponRarity;
|
final ItemRarity? weaponRarity;
|
||||||
|
|
||||||
|
/// 상대 종족 ID (PvP 모드: 설정 시 몬스터 대신 캐릭터 표시)
|
||||||
|
final String? opponentRaceId;
|
||||||
|
|
||||||
|
/// 상대 방패 장착 여부 (PvP 모드)
|
||||||
|
final bool opponentHasShield;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AsciiAnimationCard> createState() => _AsciiAnimationCardState();
|
State<AsciiAnimationCard> createState() => _AsciiAnimationCardState();
|
||||||
}
|
}
|
||||||
@@ -208,7 +216,9 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
oldWidget.shieldName != widget.shieldName ||
|
oldWidget.shieldName != widget.shieldName ||
|
||||||
oldWidget.monsterLevel != widget.monsterLevel ||
|
oldWidget.monsterLevel != widget.monsterLevel ||
|
||||||
oldWidget.raceId != widget.raceId ||
|
oldWidget.raceId != widget.raceId ||
|
||||||
oldWidget.weaponRarity != widget.weaponRarity) {
|
oldWidget.weaponRarity != widget.weaponRarity ||
|
||||||
|
oldWidget.opponentRaceId != widget.opponentRaceId ||
|
||||||
|
oldWidget.opponentHasShield != widget.opponentHasShield) {
|
||||||
_updateAnimation();
|
_updateAnimation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,6 +455,8 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
monsterSize: monsterSize,
|
monsterSize: monsterSize,
|
||||||
raceId: widget.raceId,
|
raceId: widget.raceId,
|
||||||
weaponRarity: widget.weaponRarity,
|
weaponRarity: widget.weaponRarity,
|
||||||
|
opponentRaceId: widget.opponentRaceId,
|
||||||
|
opponentHasShield: widget.opponentHasShield,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 환경 타입 추론
|
// 환경 타입 추론
|
||||||
|
|||||||
Reference in New Issue
Block a user