Compare commits

...

5 Commits

Author SHA1 Message Date
JiWoong Sul
20421dafd7 feat(ui): 몬스터 등급 UI 및 SFX 연동
- GamePlayScreen 회피/방어/패리 SFX 추가
- TaskProgressPanel 몬스터 등급 표시
- EnhancedAnimationPanel/AsciiAnimationCard 개선
- MobileCarouselLayout 몬스터 등급 전달
2026-01-05 17:53:02 +09:00
JiWoong Sul
7570a4205c refactor(engine): 포션/진행 서비스 개선
- PotionService 로직 개선
- ProgressService 몬스터 등급 지원
2026-01-05 17:52:57 +09:00
JiWoong Sul
4688aff56b feat(animation): 전투 애니메이션 및 캔버스 개선
- CanvasBattleComposer 몬스터 등급별 색상 지원
- AsciiCanvasPainter/Widget 개선
- AsciiCell 스타일 확장
2026-01-05 17:52:51 +09:00
JiWoong Sul
5c8ab0d3f4 feat(core): 몬스터 등급 시스템 추가
- MonsterGrade 열거형 및 색상 정의
- GameState/ItemStats 확장
- pq_logic 유틸리티 함수 추가
- ASCII 색상 상수 추가
2026-01-05 17:52:47 +09:00
JiWoong Sul
e112378ad2 feat(audio): 회피/방어/패리 SFX 추가
- evade.mp3, block.mp3, parry.mp3 추가
- AudioService에 새 SFX 재생 지원
2026-01-05 17:52:38 +09:00
20 changed files with 635 additions and 78 deletions

BIN
assets/audio/sfx/block.mp3 Normal file

Binary file not shown.

BIN
assets/audio/sfx/evade.mp3 Normal file

Binary file not shown.

BIN
assets/audio/sfx/parry.mp3 Normal file

Binary file not shown.

View File

@@ -52,6 +52,10 @@ class AsciiCanvasPainter extends CustomPainter {
this.objectColor = AsciiColors.object,
this.positiveColor = AsciiColors.positive,
this.negativeColor = AsciiColors.negative,
this.rarityUncommonColor = AsciiColors.rarityUncommon,
this.rarityRareColor = AsciiColors.rarityRare,
this.rarityEpicColor = AsciiColors.rarityEpic,
this.rarityLegendaryColor = AsciiColors.rarityLegendary,
});
/// 렌더링할 레이어 목록 (z-order 정렬 필요)
@@ -81,6 +85,22 @@ class AsciiCanvasPainter extends CustomPainter {
/// 네거티브 이펙트 색상 (테마 인식)
final Color negativeColor;
// ═══════════════════════════════════════════════════════════════════════
// 무기 등급(ItemRarity) 색상 (Phase 9)
// ═══════════════════════════════════════════════════════════════════════
/// Uncommon 등급 색상 (초록)
final Color rarityUncommonColor;
/// Rare 등급 색상 (파랑)
final Color rarityRareColor;
/// Epic 등급 색상 (보라)
final Color rarityEpicColor;
/// Legendary 등급 색상 (금색)
final Color rarityLegendaryColor;
/// Paragraph 캐시 (문자+색상+크기 조합별)
static final Map<_ParagraphCacheKey, ui.Paragraph> _paragraphCache = {};
@@ -209,6 +229,11 @@ class AsciiCanvasPainter extends CustomPainter {
AsciiCellColor.object => objectColor,
AsciiCellColor.positive => positiveColor,
AsciiCellColor.negative => negativeColor,
// 무기 등급 색상 (Phase 9)
AsciiCellColor.rarityUncommon => rarityUncommonColor,
AsciiCellColor.rarityRare => rarityRareColor,
AsciiCellColor.rarityEpic => rarityEpicColor,
AsciiCellColor.rarityLegendary => rarityLegendaryColor,
};
}

View File

@@ -46,6 +46,12 @@ class AsciiCanvasWidget extends StatelessWidget {
final posColor = AsciiColors.positiveOf(context);
final negColor = AsciiColors.negativeOf(context);
// 무기 등급 색상 (Phase 9)
final uncommonColor = AsciiColors.rarityUncommonOf(context);
final rareColor = AsciiColors.rarityRareOf(context);
final epicColor = AsciiColors.rarityEpicOf(context);
final legendaryColor = AsciiColors.rarityLegendaryOf(context);
return RepaintBoundary(
child: CustomPaint(
painter: AsciiCanvasPainter(
@@ -58,6 +64,10 @@ class AsciiCanvasWidget extends StatelessWidget {
objectColor: objColor,
positiveColor: posColor,
negativeColor: negColor,
rarityUncommonColor: uncommonColor,
rarityRareColor: rareColor,
rarityEpicColor: epicColor,
rarityLegendaryColor: legendaryColor,
),
size: Size.infinite,
isComplex: true,

View File

@@ -1,4 +1,4 @@
/// ASCII 셀 색상 (4색 팔레트)
/// ASCII 셀 색상 (4색 팔레트 + 무기 등급 색상)
enum AsciiCellColor {
/// 배경색 (검정)
background,
@@ -6,11 +6,27 @@ enum AsciiCellColor {
/// 오브젝트 (흰색) - 캐릭터, 몬스터, 지형
object,
/// 포지티브 이펙트 (시안) - !, +, =, >, <
/// 포지티브 이펙트 (시안) - !, +, =, >, < / common 무기
positive,
/// 네거티브 이펙트 (마젠타) - *, ~
negative,
// ═══════════════════════════════════════════════════════════════════════
// 무기 등급(ItemRarity) 색상 (Phase 9)
// ═══════════════════════════════════════════════════════════════════════
/// Uncommon 등급 (초록)
rarityUncommon,
/// Rare 등급 (파랑)
rarityRare,
/// Epic 등급 (보라)
rarityEpic,
/// Legendary 등급 (금색)
rarityLegendary,
}
/// 단일 ASCII 셀 데이터

View File

@@ -8,6 +8,7 @@ import 'package:asciineverdie/src/core/animation/monster_size.dart';
import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
import 'package:asciineverdie/src/core/animation/weapon_category.dart';
import 'package:asciineverdie/src/core/animation/weapon_effects.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
/// Canvas용 전투 프레임 합성기
///
@@ -20,6 +21,7 @@ class CanvasBattleComposer {
required this.monsterCategory,
required this.monsterSize,
this.raceId,
this.weaponRarity,
});
final WeaponCategory weaponCategory;
@@ -30,6 +32,9 @@ class CanvasBattleComposer {
/// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션)
final String? raceId;
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
final ItemRarity? weaponRarity;
/// 프레임 상수
static const int frameWidth = 60;
static const int frameHeight = 8;
@@ -43,6 +48,13 @@ class CanvasBattleComposer {
EnvironmentType environment,
int globalTick, {
AttackerType attacker = AttackerType.none,
bool isCritical = false,
bool isEvade = false,
bool isMiss = false,
bool isDebuff = false,
bool isDot = false,
bool isBlock = false,
bool isParry = false,
}) {
final layers = <AsciiLayer>[
_createBackgroundLayer(environment, globalTick),
@@ -61,6 +73,36 @@ class CanvasBattleComposer {
}
}
// 텍스트 이펙트 레이어 (Phase 10~11)
// 크리티컬 텍스트 (상단 중앙)
if (isCritical && (phase == BattlePhase.attack || phase == BattlePhase.hit)) {
layers.add(_createCriticalTextLayer(subFrame));
}
// 회피 텍스트 (캐릭터 위)
if (isEvade) {
layers.add(_createEvadeTextLayer(subFrame));
}
// 미스 텍스트 (몬스터 위)
if (isMiss) {
layers.add(_createMissTextLayer(subFrame));
}
// 디버프 텍스트 (몬스터 위)
if (isDebuff) {
layers.add(_createDebuffTextLayer(subFrame));
}
// DOT 텍스트 (몬스터 위)
if (isDot) {
layers.add(_createDotTextLayer(subFrame));
}
// 블록 텍스트 (캐릭터 위)
if (isBlock) {
layers.add(_createBlockTextLayer(subFrame));
}
// 패리 텍스트 (캐릭터 위)
if (isParry) {
layers.add(_createParryTextLayer(subFrame));
}
// z-order 정렬
layers.sort((a, b) => a.zIndex.compareTo(b.zIndex));
@@ -262,7 +304,15 @@ class CanvasBattleComposer {
if (effectLines.isEmpty) return null;
final cells = _spriteToCells(effectLines);
// Phase 9: 플레이어 공격 시 무기 등급 색상 적용
final List<List<AsciiCell>> cells;
if (attacker == AttackerType.player && weaponRarity != null) {
// 무기 등급에 따른 이펙트 색상
cells = _spriteToCellsWithColor(effectLines, weaponRarity!.effectCellColor);
} else {
// 기본 색상 (자동 색상 결정)
cells = _spriteToCells(effectLines);
}
// 이펙트 높이에 따른 동적 Y 위치 (캔버스 하단 기준)
final effectHeight = effectLines.length;
@@ -276,6 +326,119 @@ class CanvasBattleComposer {
);
}
/// 크리티컬 텍스트 레이어 생성 (z=4, 최상위)
///
/// 크리티컬 히트 시 "*CRIT!*" 텍스트를 화면 상단 중앙에 표시.
/// 노란색(positive) 색상으로 강조.
AsciiLayer _createCriticalTextLayer(int subFrame) {
// 프레임별 텍스트 애니메이션 (반짝임 효과)
final textLines = subFrame % 2 == 0
? _critTextFrames[0]
: _critTextFrames[1];
final cells = textLines.map((String line) {
return line.split('').map((String char) {
if (char == ' ') return AsciiCell.empty;
return AsciiCell(char: char, color: AsciiCellColor.positive);
}).toList();
}).toList();
// 화면 상단 중앙 배치 (Y=0~1, X=중앙)
final textWidth = textLines.isNotEmpty ? textLines[0].length : 0;
final offsetX = (frameWidth - textWidth) ~/ 2;
return AsciiLayer(
cells: cells,
zIndex: 4, // 이펙트(z=3) 위에 표시
offsetX: offsetX,
offsetY: 0,
);
}
/// 회피 텍스트 레이어 생성 (캐릭터 위, positive)
AsciiLayer _createEvadeTextLayer(int subFrame) {
return _createTextLayer(
frames: _evadeTextFrames,
subFrame: subFrame,
color: AsciiCellColor.positive,
offsetX: 15, // 캐릭터 위
);
}
/// 미스 텍스트 레이어 생성 (몬스터 위, negative)
AsciiLayer _createMissTextLayer(int subFrame) {
return _createTextLayer(
frames: _missTextFrames,
subFrame: subFrame,
color: AsciiCellColor.negative,
offsetX: 35, // 몬스터 위
);
}
/// 디버프 텍스트 레이어 생성 (몬스터 위, negative)
AsciiLayer _createDebuffTextLayer(int subFrame) {
return _createTextLayer(
frames: _debuffTextFrames,
subFrame: subFrame,
color: AsciiCellColor.negative,
offsetX: 35, // 몬스터 위
);
}
/// DOT 텍스트 레이어 생성 (몬스터 위, negative)
AsciiLayer _createDotTextLayer(int subFrame) {
return _createTextLayer(
frames: _dotTextFrames,
subFrame: subFrame,
color: AsciiCellColor.negative,
offsetX: 35, // 몬스터 위
);
}
/// 블록 텍스트 레이어 생성 (캐릭터 위, positive)
AsciiLayer _createBlockTextLayer(int subFrame) {
return _createTextLayer(
frames: _blockTextFrames,
subFrame: subFrame,
color: AsciiCellColor.positive,
offsetX: 15, // 캐릭터 위
);
}
/// 패리 텍스트 레이어 생성 (캐릭터 위, positive)
AsciiLayer _createParryTextLayer(int subFrame) {
return _createTextLayer(
frames: _parryTextFrames,
subFrame: subFrame,
color: AsciiCellColor.positive,
offsetX: 15, // 캐릭터 위
);
}
/// 공통 텍스트 레이어 생성 헬퍼
AsciiLayer _createTextLayer({
required List<List<String>> frames,
required int subFrame,
required AsciiCellColor color,
required int offsetX,
}) {
final textLines = frames[subFrame % 2];
final cells = textLines.map((String line) {
return line.split('').map((String char) {
if (char == ' ') return AsciiCell.empty;
return AsciiCell(char: char, color: color);
}).toList();
}).toList();
return AsciiLayer(
cells: cells,
zIndex: 4,
offsetX: offsetX,
offsetY: 0,
);
}
/// 몬스터 공격 이펙트 (← 방향)
List<String> _getMonsterAttackEffect(BattlePhase phase, int subFrame) {
return switch (phase) {
@@ -293,6 +456,21 @@ class CanvasBattleComposer {
}).toList();
}
/// 문자열 스프라이트를 지정된 색상으로 AsciiCell 2D 배열로 변환 (Phase 9)
///
/// 이펙트 문자(공백 아닌 문자)에 지정 색상 적용
List<List<AsciiCell>> _spriteToCellsWithColor(
List<String> lines,
AsciiCellColor effectColor,
) {
return lines.map((line) {
return line.split('').map((char) {
if (char == ' ' || char.isEmpty) return AsciiCell.empty;
return AsciiCell(char: char, color: effectColor);
}).toList();
}).toList();
}
/// 문자열 스프라이트를 오른쪽 정렬된 AsciiCell 2D 배열로 변환
List<List<AsciiCell>> _spriteToRightAlignedCells(
List<String> lines,
@@ -1129,3 +1307,53 @@ const _monsterHitFrames = <List<String>>[
[r' *SLASH!* ', r' **** ', r' <----- ', r' **** ', r' '],
[r'*ATTACK!* ', r' **** ', r' <---- ', r' **** ', r' '],
];
// ============================================================================
// 크리티컬 텍스트 프레임 (2줄, Phase 10)
// ============================================================================
/// 크리티컬 히트 텍스트 프레임 (반짝임 애니메이션)
const _critTextFrames = <List<String>>[
[r'*CRITICAL!*', r' ========='],
[r'=CRITICAL!=', r' *********'],
];
// ============================================================================
// 전투 텍스트 이펙트 프레임 (Phase 11)
// ============================================================================
/// 회피 텍스트 프레임 (플레이어 회피 성공)
const _evadeTextFrames = <List<String>>[
[r'*EVADE!*', r'========'],
[r'=EVADE!=', r'********'],
];
/// 미스 텍스트 프레임 (플레이어 공격 빗나감)
const _missTextFrames = <List<String>>[
[r'*MISS!*', r'======='],
[r'=MISS!=', r'*******'],
];
/// 디버프 텍스트 프레임 (적에게 디버프 적용)
const _debuffTextFrames = <List<String>>[
[r'*DEBUFF!*', r'========='],
[r'=DEBUFF!=', r'*********'],
];
/// DOT 텍스트 프레임 (지속 피해)
const _dotTextFrames = <List<String>>[
[r'*DOT!*', r'======'],
[r'=DOT!=', r'******'],
];
/// 블록 텍스트 프레임 (방패 방어)
const _blockTextFrames = <List<String>>[
[r'*BLOCK!*', r'========'],
[r'=BLOCK!=', r'********'],
];
/// 패리 텍스트 프레임 (무기 쳐내기)
const _parryTextFrames = <List<String>>[
[r'*PARRY!*', r'========'],
[r'=PARRY!=', r'********'],
];

View File

@@ -323,6 +323,15 @@ enum SfxType {
/// 퀘스트 완료
questComplete,
/// 회피 (Phase 11)
evade,
/// 방패 방어 (Phase 11)
block,
/// 무기 쳐내기 (Phase 11)
parry,
}
/// BgmType을 파일명으로 변환
@@ -340,5 +349,8 @@ extension SfxTypeExtension on SfxType {
SfxType.click => 'click',
SfxType.levelUp => 'level_up',
SfxType.questComplete => 'quest_complete',
SfxType.evade => 'evade',
SfxType.block => 'block',
SfxType.parry => 'parry',
};
}

View File

@@ -32,6 +32,26 @@ class AsciiColors {
static Color backgroundOf(BuildContext context) =>
RetroColors.isDarkMode(context) ? background : _lightBackground;
// ═══════════════════════════════════════════════════════════════════════
// 무기 등급(ItemRarity) 색상 Getter (테마 인식, Phase 9)
// ═══════════════════════════════════════════════════════════════════════
/// Uncommon 등급 색상 - 테마 인식
static Color rarityUncommonOf(BuildContext context) =>
RetroColors.isDarkMode(context) ? rarityUncommon : _lightRarityUncommon;
/// Rare 등급 색상 - 테마 인식
static Color rarityRareOf(BuildContext context) =>
RetroColors.isDarkMode(context) ? rarityRare : _lightRarityRare;
/// Epic 등급 색상 - 테마 인식
static Color rarityEpicOf(BuildContext context) =>
RetroColors.isDarkMode(context) ? rarityEpic : _lightRarityEpic;
/// Legendary 등급 색상 - 테마 인식
static Color rarityLegendaryOf(BuildContext context) =>
RetroColors.isDarkMode(context) ? rarityLegendary : _lightRarityLegendary;
// ═══════════════════════════════════════════════════════════════════════
// 라이트 모드 색상 (양피지/크림 기반)
// ═══════════════════════════════════════════════════════════════════════
@@ -48,6 +68,18 @@ class AsciiColors {
/// 라이트 모드 배경 (양피지 크림)
static const Color _lightBackground = Color(0xFFF5E6C8);
/// 라이트 모드 Uncommon 등급 (진한 초록)
static const Color _lightRarityUncommon = Color(0xFF008800);
/// 라이트 모드 Rare 등급 (진한 파랑)
static const Color _lightRarityRare = Color(0xFF0055AA);
/// 라이트 모드 Epic 등급 (진한 보라)
static const Color _lightRarityEpic = Color(0xFF660099);
/// 라이트 모드 Legendary 등급 (진한 금색)
static const Color _lightRarityLegendary = Color(0xFFCC7700);
// ═══════════════════════════════════════════════════════════════════════
// 레거시 정적 색상 (다크 모드 기본값 / context 없는 곳에서 사용)
// ═══════════════════════════════════════════════════════════════════════
@@ -64,6 +96,22 @@ class AsciiColors {
/// 배경 색상
static const Color background = Colors.black;
// ═══════════════════════════════════════════════════════════════════════
// 무기 등급(ItemRarity) 정적 색상 (다크 모드 기본값, Phase 9)
// ═══════════════════════════════════════════════════════════════════════
/// Uncommon 등급 (밝은 초록)
static const Color rarityUncommon = Color(0xFF00FF00);
/// Rare 등급 (밝은 파랑)
static const Color rarityRare = Color(0xFF0088FF);
/// Epic 등급 (밝은 보라)
static const Color rarityEpic = Color(0xFF9900FF);
/// Legendary 등급 (밝은 금색)
static const Color rarityLegendary = Color(0xFFFFAA00);
/// 상황에 따른 색상 반환
static Color forContext(AsciiColorContext context) {
return switch (context) {

View File

@@ -1,4 +1,7 @@
import 'dart:math' as math;
import 'package:asciineverdie/data/potion_data.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/model/potion.dart';
/// 물약 서비스
@@ -357,19 +360,35 @@ class PotionService {
///
/// 전투 승리 시 물약 드랍 여부 결정 및 물약 획득
/// [playerLevel] 플레이어 레벨 (드랍 확률 및 티어 결정)
/// [monsterLevel] 몬스터 레벨 (티어 결정에 영향)
/// [monsterGrade] 몬스터 등급 (드랍 확률 보너스)
/// [inventory] 현재 물약 인벤토리
/// [roll] 0~99 범위의 난수 (드랍 확률 판정)
/// [typeRoll] 0~99 범위의 난수 (HP/MP 결정)
/// Returns: (업데이트된 인벤토리, 드랍된 물약 또는 null)
(PotionInventory, Potion?) tryPotionDrop({
required int playerLevel,
required int monsterLevel,
required MonsterGrade monsterGrade,
required PotionInventory inventory,
required int roll,
required int typeRoll,
}) {
// 드랍 확률 계산
final dropChance = (baseDropChance + playerLevel * dropChancePerLevel)
// 기본 드랍 확률 계산
var dropChance = (baseDropChance + playerLevel * dropChancePerLevel)
.clamp(baseDropChance, maxDropChance);
// 몬스터 등급 보너스 (Elite +5%, Boss +15%)
dropChance += monsterGrade.potionDropBonus;
// 몬스터 레벨 > 플레이어 레벨이면 추가 확률 (+1%/레벨 차이)
if (monsterLevel > playerLevel) {
dropChance += (monsterLevel - playerLevel) * 0.01;
}
// 최대 확률 제한 (50%)
dropChance = dropChance.clamp(0.0, 0.5);
final dropThreshold = (dropChance * 100).round();
// 드랍 실패
@@ -380,8 +399,9 @@ class PotionService {
// 물약 타입 결정 (60% HP, 40% MP)
final isHpPotion = typeRoll < 60;
// 레벨 기반 티어 결정
final tier = PotionData.tierForLevel(playerLevel);
// 티어 결정: max(플레이어 레벨, 몬스터 레벨) 기반
final effectiveLevel = math.max(playerLevel, monsterLevel);
final tier = PotionData.tierForLevel(effectiveLevel);
// 물약 선택
final Potion? potion;

View File

@@ -14,6 +14,7 @@ import 'package:asciineverdie/src/core/model/equipment_item.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/model/potion.dart';
import 'package:asciineverdie/src/core/model/pq_config.dart';
import 'package:asciineverdie/src/core/model/skill.dart';
@@ -591,6 +592,7 @@ class ProgressService {
monsterBaseName: monsterResult.baseName,
monsterPart: monsterResult.part,
monsterLevel: monsterResult.level,
monsterGrade: monsterResult.grade,
),
currentCombat: combatState,
);
@@ -646,6 +648,7 @@ class ProgressService {
monsterBaseName: 'Glitch God',
monsterPart: '*', // 특수 전리품
monsterLevel: glitchGod.level,
monsterGrade: MonsterGrade.boss, // 최종 보스는 항상 boss 등급
),
currentCombat: combatState,
);
@@ -963,8 +966,12 @@ class ProgressService {
// 물약 드랍 시도
final potionService = const PotionService();
final rng = resultState.rng;
final monsterLevel = taskInfo.monsterLevel ?? resultState.traits.level;
final monsterGrade = taskInfo.monsterGrade ?? MonsterGrade.normal;
final (updatedPotionInventory, droppedPotion) = potionService.tryPotionDrop(
playerLevel: resultState.traits.level,
monsterLevel: monsterLevel,
monsterGrade: monsterGrade,
inventory: resultState.potionInventory,
roll: rng.nextInt(100),
typeRoll: rng.nextInt(100),

View File

@@ -5,6 +5,7 @@ import 'package:asciineverdie/src/core/model/combat_state.dart';
import 'package:asciineverdie/src/core/model/equipment_item.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/model/potion.dart';
import 'package:asciineverdie/src/core/model/skill.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
@@ -280,6 +281,7 @@ class TaskInfo {
this.monsterBaseName,
this.monsterPart,
this.monsterLevel,
this.monsterGrade,
});
final String caption;
@@ -294,6 +296,9 @@ class TaskInfo {
/// 킬 태스크의 몬스터 레벨 (애니메이션 크기 결정용)
final int? monsterLevel;
/// 킬 태스크의 몬스터 등급 (Normal/Elite/Boss)
final MonsterGrade? monsterGrade;
factory TaskInfo.empty() =>
const TaskInfo(caption: '', type: TaskType.neutral);
@@ -303,6 +308,7 @@ class TaskInfo {
String? monsterBaseName,
String? monsterPart,
int? monsterLevel,
MonsterGrade? monsterGrade,
}) {
return TaskInfo(
caption: caption ?? this.caption,
@@ -310,6 +316,7 @@ class TaskInfo {
monsterBaseName: monsterBaseName ?? this.monsterBaseName,
monsterPart: monsterPart ?? this.monsterPart,
monsterLevel: monsterLevel ?? this.monsterLevel,
monsterGrade: monsterGrade ?? this.monsterGrade,
);
}
}

View File

@@ -1,3 +1,5 @@
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
/// 아이템 희귀도
enum ItemRarity {
common,
@@ -23,6 +25,17 @@ enum ItemRarity {
epic => 400,
legendary => 1000,
};
/// 공격 이펙트 셀 색상 (Phase 9: 무기 등급별 이펙트)
///
/// common은 기본 positive(시안), 나머지는 등급별 고유 색상
AsciiCellColor get effectCellColor => switch (this) {
ItemRarity.common => AsciiCellColor.positive,
ItemRarity.uncommon => AsciiCellColor.rarityUncommon,
ItemRarity.rare => AsciiCellColor.rarityRare,
ItemRarity.epic => AsciiCellColor.rarityEpic,
ItemRarity.legendary => AsciiCellColor.rarityLegendary,
};
}
/// 아이템 스탯 보정치

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
/// 몬스터 등급 (드랍 품질 및 UI 표시에 영향)
enum MonsterGrade {
/// 일반 몬스터 (85% 확률)
/// - 기본 드랍 확률
/// - UI: 기본 색상, 접두사 없음
normal,
/// 정예 몬스터 (12% 확률)
/// - 물약 드랍 +5%, 아이템 품질 향상
/// - UI: 파란색, ★ 접두사
elite,
/// 보스 몬스터 (3% 확률)
/// - 물약 드랍 +15%, 최고 아이템 품질
/// - UI: 금색, ★★★ 접두사
boss,
}
/// MonsterGrade 확장 메서드
extension MonsterGradeExtension on MonsterGrade {
/// 물약 드랍 확률 보너스 (0.0 ~ 1.0)
double get potionDropBonus => switch (this) {
MonsterGrade.normal => 0.0,
MonsterGrade.elite => 0.05, // +5%
MonsterGrade.boss => 0.15, // +15%
};
/// UI 표시용 접두사 (몬스터 이름 앞에 붙음)
String get displayPrefix => switch (this) {
MonsterGrade.normal => '',
MonsterGrade.elite => '',
MonsterGrade.boss => '★★★ ',
};
/// 스탯 배율 (전투력 강화)
double get statMultiplier => switch (this) {
MonsterGrade.normal => 1.0,
MonsterGrade.elite => 1.3, // +30% 스탯
MonsterGrade.boss => 1.8, // +80% 스탯
};
/// 경험치 배율
double get expMultiplier => switch (this) {
MonsterGrade.normal => 1.0,
MonsterGrade.elite => 1.5, // +50% 경험치
MonsterGrade.boss => 2.5, // +150% 경험치
};
/// UI 표시용 색상
/// - Normal: 기본 텍스트 색상 (null 반환 → 기본 스타일 사용)
/// - Elite: 파란색 (#7AA2F7)
/// - Boss: 금색 (#E0AF68)
Color? get displayColor => switch (this) {
MonsterGrade.normal => null,
MonsterGrade.elite => const Color(0xFF7AA2F7), // MP 파랑
MonsterGrade.boss => const Color(0xFFE0AF68), // 골드
};
}

View File

@@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/model/pq_config.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
import 'package:asciineverdie/src/core/util/roman.dart';
@@ -483,6 +484,25 @@ Stats winStat(Stats stats, DeterministicRandom rng) {
}
}
/// 몬스터 등급 결정 (Normal 85%, Elite 12%, Boss 3%)
/// 몬스터 레벨이 플레이어 레벨보다 높으면 상위 등급 확률 증가
MonsterGrade _determineGrade(
int monsterLevel,
int playerLevel,
DeterministicRandom rng,
) {
// 기본 확률: Normal 85%, Elite 12%, Boss 3%
// 레벨 차이에 따른 보정
final levelDiff = monsterLevel - playerLevel;
final eliteBonus = (levelDiff * 2).clamp(0, 10); // 최대 +10%
final bossBonus = (levelDiff * 0.5).clamp(0, 3).toInt(); // 최대 +3%
final roll = rng.nextInt(100);
if (roll < 3 + bossBonus) return MonsterGrade.boss;
if (roll < 15 + eliteBonus) return MonsterGrade.elite;
return MonsterGrade.normal;
}
MonsterTaskResult monsterTask(
PqConfig config,
DeterministicRandom rng,
@@ -592,11 +612,15 @@ MonsterTaskResult monsterTask(
name = l10n.indefiniteL10n(name, qty);
}
// 몬스터 등급 결정 (level = 플레이어 레벨)
final grade = _determineGrade(monsterLevel, level, rng);
return MonsterTaskResult(
displayName: name,
baseName: baseName,
level: monsterLevel * qty,
part: part,
grade: grade,
);
}
@@ -607,6 +631,7 @@ class MonsterTaskResult {
required this.baseName,
required this.level,
required this.part,
required this.grade,
});
/// 화면에 표시할 몬스터 이름 (형용사 포함, 예: "a sick Goblin")
@@ -620,6 +645,9 @@ class MonsterTaskResult {
/// 전리품 부위 (예: "claw", "tail", "*"는 WinItem 호출)
final String part;
/// 몬스터 등급 (Normal/Elite/Boss)
final MonsterGrade grade;
}
enum RewardKind { spell, equip, stat, item }

View File

@@ -302,16 +302,21 @@ class _GamePlayScreenState extends State<GamePlayScreen>
case CombatEventType.monsterAttack:
audio.playMonsterSfx('hit');
// 회피/방어 SFX (Phase 11)
case CombatEventType.playerEvade:
audio.playPlayerSfx('evade');
case CombatEventType.monsterEvade:
// 몬스터 회피 = 플레이어 공격 빗나감 (evade SFX)
audio.playPlayerSfx('evade');
case CombatEventType.playerBlock:
audio.playPlayerSfx('block');
case CombatEventType.playerParry:
audio.playPlayerSfx('parry');
// SFX 없음
case CombatEventType.dotTick:
// DOT 틱은 SFX 없음 (너무 자주 발생)
break;
case CombatEventType.playerEvade:
case CombatEventType.monsterEvade:
case CombatEventType.playerBlock:
case CombatEventType.playerParry:
// 회피/방어는 별도 SFX 없음
break;
}
}
@@ -925,6 +930,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
shieldName: state.equipment.shield,
characterLevel: state.traits.level,
monsterLevel: state.progress.currentTask.monsterLevel,
monsterGrade: state.progress.currentTask.monsterGrade,
latestCombatEvent:
state.progress.currentCombat?.recentEvents.lastOrNull,
raceId: state.traits.raceId,

View File

@@ -663,9 +663,11 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
shieldName: state.equipment.shield,
characterLevel: state.traits.level,
monsterLevel: state.progress.currentTask.monsterLevel,
monsterGrade: state.progress.currentTask.monsterGrade,
latestCombatEvent:
state.progress.currentCombat?.recentEvents.lastOrNull,
raceId: state.traits.raceId,
weaponRarity: state.equipment.weaponItem.rarity,
),
// 중앙: 캐로셀 (PageView)

View File

@@ -17,6 +17,8 @@ import 'package:asciineverdie/src/core/animation/weapon_category.dart';
import 'package:asciineverdie/src/core/constants/ascii_colors.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
/// 애니메이션 모드
enum AnimationMode {
@@ -43,9 +45,11 @@ class AsciiAnimationCard extends StatefulWidget {
this.shieldName,
this.characterLevel,
this.monsterLevel,
this.monsterGrade,
this.isPaused = false,
this.latestCombatEvent,
this.raceId,
this.weaponRarity,
});
final TaskType taskType;
@@ -73,12 +77,18 @@ class AsciiAnimationCard extends StatefulWidget {
/// 몬스터 레벨 (몬스터 크기 결정용)
final int? monsterLevel;
/// 몬스터 등급 (Normal/Elite/Boss) - 색상/접두사 표시용
final MonsterGrade? monsterGrade;
/// 최근 전투 이벤트 (애니메이션 동기화용)
final CombatEvent? latestCombatEvent;
/// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션)
final String? raceId;
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
final ItemRarity? weaponRarity;
@override
State<AsciiAnimationCard> createState() => _AsciiAnimationCardState();
}
@@ -128,6 +138,12 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
bool _showParryEffect = false;
bool _showSkillEffect = false;
// 추가 전투 이펙트 (Phase 11)
bool _showEvadeEffect = false;
bool _showMissEffect = false;
bool _showDebuffEffect = false;
bool _showDotEffect = false;
// 공격 속도 기반 동적 페이즈 프레임 수 (Phase 6)
int _eventDrivenPhaseFrames = 0;
bool _isEventDrivenPhase = false;
@@ -191,12 +207,13 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
oldWidget.weaponName != widget.weaponName ||
oldWidget.shieldName != widget.shieldName ||
oldWidget.monsterLevel != widget.monsterLevel ||
oldWidget.raceId != widget.raceId) {
oldWidget.raceId != widget.raceId ||
oldWidget.weaponRarity != widget.weaponRarity) {
_updateAnimation();
}
}
/// 전투 이벤트에 따라 애니메이션 페이즈 강제 전환 (Phase 5)
/// 전투 이벤트에 따라 애니메이션 페이즈 강제 전환 (Phase 5, 11)
void _handleCombatEvent(CombatEvent event) {
_lastEventTimestamp = event.timestamp;
@@ -204,121 +221,90 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
if (_animationMode != AnimationMode.battle) return;
// 이벤트 타입에 따라 페이즈 및 효과 결정
// (targetPhase, isCritical, isBlock, isParry, isSkill, isEvade, isMiss, isDebuff, isDot)
final (
targetPhase,
isCritical,
isBlock,
isParry,
isSkill,
isEvade,
isMiss,
isDebuff,
isDot,
) = switch (event.type) {
// 플레이어 공격 → prepare 페이즈부터 시작 (준비 동작 표시)
CombatEventType.playerAttack => (
BattlePhase.prepare,
event.isCritical,
false,
false,
false,
false, false, false, false, false, false, false,
),
// 스킬 사용 → prepare 페이즈부터 시작 + 스킬 이펙트
CombatEventType.playerSkill => (
BattlePhase.prepare,
event.isCritical,
false,
false,
true,
false, false, true, false, false, false, false,
),
// 몬스터 공격 → prepare 페이즈부터 시작
CombatEventType.monsterAttack => (
BattlePhase.prepare,
false,
false,
false,
false,
false, false, false, false, false, false, false, false,
),
// 블록 → hit 페이즈 + 블록 이펙트
// 블록 → hit 페이즈 + 블록 이펙트 + 텍스트
CombatEventType.playerBlock => (
BattlePhase.hit,
false,
true,
false,
false,
false, true, false, false, false, false, false, false,
),
// 패리 → hit 페이즈 + 패리 이펙트
// 패리 → hit 페이즈 + 패리 이펙트 + 텍스트
CombatEventType.playerParry => (
BattlePhase.hit,
false,
false,
true,
false,
false, false, true, false, false, false, false, false,
),
// 회피 → recover 페이즈 (빠른 회피 동작)
// 플레이어 회피 → recover 페이즈 + 회피 텍스트
CombatEventType.playerEvade => (
BattlePhase.recover,
false,
false,
false,
false,
false, false, false, false, true, false, false, false,
),
// 몬스터 회피 → idle 페이즈 + 미스 텍스트
CombatEventType.monsterEvade => (
BattlePhase.idle,
false,
false,
false,
false,
false, false, false, false, false, true, false, false,
),
// 회복/버프 → idle 페이즈 유지
CombatEventType.playerHeal => (
BattlePhase.idle,
false,
false,
false,
false,
false, false, false, false, false, false, false, false,
),
CombatEventType.playerBuff => (
BattlePhase.idle,
false,
false,
false,
false,
false, false, false, false, false, false, false, false,
),
// 디버프 적용 → idle 페이즈 유지
// 디버프 적용 → idle 페이즈 + 디버프 텍스트
CombatEventType.playerDebuff => (
BattlePhase.idle,
false,
false,
false,
false,
false, false, false, false, false, false, true, false,
),
// DOT 틱 → attack 페이즈 (지속 피해)
// DOT 틱 → attack 페이즈 + DOT 텍스트
CombatEventType.dotTick => (
BattlePhase.attack,
false,
false,
false,
false,
false, false, false, false, false, false, false, true,
),
// 물약 사용 → idle 페이즈 유지
CombatEventType.playerPotion => (
BattlePhase.idle,
false,
false,
false,
false,
false, false, false, false, false, false, false, false,
),
// 물약 드랍 → idle 페이즈 유지
CombatEventType.potionDrop => (
BattlePhase.idle,
false,
false,
false,
false,
false, false, false, false, false, false, false, false,
),
};
@@ -330,6 +316,10 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_showBlockEffect = isBlock;
_showParryEffect = isParry;
_showSkillEffect = isSkill;
_showEvadeEffect = isEvade;
_showMissEffect = isMiss;
_showDebuffEffect = isDebuff;
_showDotEffect = isDot;
// 페이즈 인덱스 동기화
_phaseIndex = _battlePhaseSequence.indexWhere((p) => p.$1 == targetPhase);
@@ -454,6 +444,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
monsterCategory: monsterCategory,
monsterSize: monsterSize,
raceId: widget.raceId,
weaponRarity: widget.weaponRarity,
);
// 환경 타입 추론
@@ -483,6 +474,10 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_showBlockEffect = false;
_showParryEffect = false;
_showSkillEffect = false;
_showEvadeEffect = false;
_showMissEffect = false;
_showDebuffEffect = false;
_showDotEffect = false;
// 공격자 타입 및 이벤트 기반 페이즈 리셋 (idle 페이즈 진입 시에만)
// 공격 사이클(prepare→attack→hit→recover) 동안 유지 (Bug fix)
if (_battlePhaseSequence[_phaseIndex].$1 == BattlePhase.idle) {
@@ -513,6 +508,13 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_environment,
_globalTick,
attacker: _currentAttacker,
isCritical: _showCriticalEffect,
isEvade: _showEvadeEffect,
isMiss: _showMissEffect,
isDebuff: _showDebuffEffect,
isDot: _showDotEffect,
isBlock: _showBlockEffect,
isParry: _showParryEffect,
) ??
[AsciiLayer.empty()],
AnimationMode.walking =>

View File

@@ -5,6 +5,8 @@ import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/core/model/combat_state.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
/// 모바일용 확장 애니메이션 패널
@@ -29,8 +31,10 @@ class EnhancedAnimationPanel extends StatefulWidget {
this.shieldName,
this.characterLevel,
this.monsterLevel,
this.monsterGrade,
this.latestCombatEvent,
this.raceId,
this.weaponRarity,
});
final ProgressState progress;
@@ -45,11 +49,17 @@ class EnhancedAnimationPanel extends StatefulWidget {
final String? shieldName;
final int? characterLevel;
final int? monsterLevel;
/// 몬스터 등급 (Normal/Elite/Boss) - UI 색상/접두사 표시용
final MonsterGrade? monsterGrade;
final CombatEvent? latestCombatEvent;
/// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션)
final String? raceId;
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
final ItemRarity? weaponRarity;
@override
State<EnhancedAnimationPanel> createState() => _EnhancedAnimationPanelState();
}
@@ -185,9 +195,11 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
shieldName: widget.shieldName,
characterLevel: widget.characterLevel,
monsterLevel: widget.monsterLevel,
monsterGrade: widget.monsterGrade,
isPaused: widget.isPaused,
latestCombatEvent: widget.latestCombatEvent,
raceId: widget.raceId,
weaponRarity: widget.weaponRarity,
),
),
@@ -630,11 +642,34 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
? (task.position / task.max).clamp(0.0, 1.0)
: 0.0;
// 몬스터 등급에 따른 접두사와 색상
final grade = widget.monsterGrade;
final isKillTask = widget.progress.currentTask.type == TaskType.kill;
final gradePrefix =
(isKillTask && grade != null) ? grade.displayPrefix : '';
final gradeColor =
(isKillTask && grade != null) ? grade.displayColor : null;
return Column(
children: [
// 캡션
Text(
_getStatusMessage(),
// 캡션 (등급에 따른 접두사 및 색상)
Text.rich(
TextSpan(
children: [
if (gradePrefix.isNotEmpty)
TextSpan(
text: gradePrefix,
style: TextStyle(
color: gradeColor,
fontWeight: FontWeight.bold,
),
),
TextSpan(
text: _getStatusMessage(),
style: gradeColor != null ? TextStyle(color: gradeColor) : null,
),
],
),
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
maxLines: 1,

View File

@@ -5,6 +5,7 @@ import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
/// 상단 패널: ASCII 애니메이션 + Task Progress 바
@@ -23,6 +24,7 @@ class TaskProgressPanel extends StatelessWidget {
this.shieldName,
this.characterLevel,
this.monsterLevel,
this.monsterGrade,
this.latestCombatEvent,
this.raceId,
});
@@ -44,6 +46,9 @@ class TaskProgressPanel extends StatelessWidget {
final int? characterLevel;
final int? monsterLevel;
/// 몬스터 등급 (Normal/Elite/Boss) - UI 색상/접두사 표시용
final MonsterGrade? monsterGrade;
/// 최근 전투 이벤트 (애니메이션 동기화용, Phase 5)
final CombatEvent? latestCombatEvent;
@@ -74,6 +79,7 @@ class TaskProgressPanel extends StatelessWidget {
shieldName: shieldName,
characterLevel: characterLevel,
monsterLevel: monsterLevel,
monsterGrade: monsterGrade,
isPaused: isPaused,
latestCombatEvent: latestCombatEvent,
raceId: raceId,
@@ -87,11 +93,7 @@ class TaskProgressPanel extends StatelessWidget {
_buildPauseButton(context),
const SizedBox(width: 8),
Expanded(
child: Text(
_getStatusMessage(context),
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
child: _buildStatusMessage(context),
),
const SizedBox(width: 8),
_buildSpeedButton(context),
@@ -153,6 +155,42 @@ class TaskProgressPanel extends StatelessWidget {
);
}
/// 상태 메시지 위젯 (등급에 따른 접두사 및 색상 적용)
Widget _buildStatusMessage(BuildContext context) {
final message = _getStatusMessage(context);
// 몬스터 등급에 따른 접두사와 색상
final grade = monsterGrade;
final isKillTask = progress.currentTask.type == TaskType.kill;
final gradePrefix =
(isKillTask && grade != null) ? grade.displayPrefix : '';
final gradeColor =
(isKillTask && grade != null) ? grade.displayColor : null;
return Text.rich(
TextSpan(
children: [
if (gradePrefix.isNotEmpty)
TextSpan(
text: gradePrefix,
style: TextStyle(
color: gradeColor,
fontWeight: FontWeight.bold,
),
),
TextSpan(
text: message,
style: gradeColor != null ? TextStyle(color: gradeColor) : null,
),
],
),
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
/// 현재 상태에 맞는 메시지 반환
///
/// 특수 애니메이션(부활 등) 중에는 해당 메시지 표시