diff --git a/lib/data/game_text_l10n.dart b/lib/data/game_text_l10n.dart index 158f75e..7a2946e 100644 --- a/lib/data/game_text_l10n.dart +++ b/lib/data/game_text_l10n.dart @@ -1284,6 +1284,12 @@ String get hofCombatStats { return 'Combat Stats'; } +String get hofCharacterPreview { + if (isKoreanLocale) return '캐릭터 미리보기'; + if (isJapaneseLocale) return 'キャラクタープレビュー'; + return 'Character Preview'; +} + String get buttonClose { if (isKoreanLocale) return '닫기'; if (isJapaneseLocale) return '閉じる'; diff --git a/lib/src/core/animation/canvas/canvas_town_composer.dart b/lib/src/core/animation/canvas/canvas_town_composer.dart index b0edd68..a50b650 100644 --- a/lib/src/core/animation/canvas/canvas_town_composer.dart +++ b/lib/src/core/animation/canvas/canvas_town_composer.dart @@ -1,11 +1,16 @@ import 'package:askiineverdie/src/core/animation/canvas/ascii_cell.dart'; import 'package:askiineverdie/src/core/animation/canvas/ascii_layer.dart'; +import 'package:askiineverdie/src/core/animation/race_character_frames.dart'; /// Canvas용 마을/상점 애니메이션 합성기 /// /// 마을 배경 + 상점 건물 + 캐릭터 +/// Phase 4: 종족별 캐릭터 프레임 지원 class CanvasTownComposer { - const CanvasTownComposer(); + const CanvasTownComposer({this.raceId}); + + /// 종족 ID (종족별 캐릭터 프레임 선택용) + final String? raceId; /// 프레임 상수 static const int frameWidth = 60; @@ -67,9 +72,24 @@ class CanvasTownComposer { } /// 캐릭터 레이어 생성 (z=2) + /// Phase 4: 종족별 프레임 지원 AsciiLayer _createCharacterLayer(int globalTick) { - final frameIndex = globalTick % _shopIdleFrames.length; - final charFrame = _shopIdleFrames[frameIndex]; + final frameIndex = globalTick % 4; // 4프레임 루프 + List charFrame; + + // 종족별 프레임 사용 시도 + if (raceId != null && raceId!.isNotEmpty) { + final raceData = RaceCharacterFrames.get(raceId!); + if (raceData != null) { + // idle 프레임 직접 사용 (마을에서는 서있는 자세) + final idleFrame = raceData.idle[frameIndex % raceData.idle.length]; + charFrame = idleFrame.lines; + } else { + charFrame = _shopIdleFrames[frameIndex]; + } + } else { + charFrame = _shopIdleFrames[frameIndex]; + } final cells = _spriteToCells(charFrame); diff --git a/lib/src/core/animation/canvas/canvas_walking_composer.dart b/lib/src/core/animation/canvas/canvas_walking_composer.dart index bc9bcd1..5044f96 100644 --- a/lib/src/core/animation/canvas/canvas_walking_composer.dart +++ b/lib/src/core/animation/canvas/canvas_walking_composer.dart @@ -2,12 +2,17 @@ import 'package:askiineverdie/src/core/animation/background_data.dart'; import 'package:askiineverdie/src/core/animation/background_layer.dart'; import 'package:askiineverdie/src/core/animation/canvas/ascii_cell.dart'; import 'package:askiineverdie/src/core/animation/canvas/ascii_layer.dart'; +import 'package:askiineverdie/src/core/animation/race_character_frames.dart'; /// Canvas용 걷기 애니메이션 합성기 /// /// 배경 스크롤 + 걷는 캐릭터 +/// Phase 4: 종족별 캐릭터 프레임 지원 class CanvasWalkingComposer { - const CanvasWalkingComposer(); + const CanvasWalkingComposer({this.raceId}); + + /// 종족 ID (종족별 캐릭터 프레임 선택용) + final String? raceId; /// 프레임 상수 static const int frameWidth = 60; @@ -54,9 +59,24 @@ class CanvasWalkingComposer { } /// 걷는 캐릭터 레이어 생성 (z=1) + /// Phase 4: 종족별 프레임 지원 AsciiLayer _createCharacterLayer(int globalTick) { - final frameIndex = globalTick % _walkingFrames.length; - final charFrame = _walkingFrames[frameIndex]; + final frameIndex = globalTick % 4; // 4프레임 루프 + List charFrame; + + // 종족별 프레임 사용 시도 + if (raceId != null && raceId!.isNotEmpty) { + final raceData = RaceCharacterFrames.get(raceId!); + if (raceData != null) { + // idle 프레임을 기반으로 걷기 애니메이션 생성 + final idleFrame = raceData.idle[frameIndex % raceData.idle.length]; + charFrame = _animateWalking(idleFrame.lines, frameIndex); + } else { + charFrame = _walkingFrames[frameIndex]; + } + } else { + charFrame = _walkingFrames[frameIndex]; + } final cells = _spriteToCells(charFrame); @@ -68,6 +88,22 @@ class CanvasWalkingComposer { return AsciiLayer(cells: cells, zIndex: 1, offsetX: charX, offsetY: charY); } + /// idle 프레임 기반 걷기 애니메이션 생성 + /// 머리와 몸통은 유지, 다리만 걷는 동작으로 변경 + List _animateWalking(List idleLines, int frameIndex) { + if (idleLines.length < 3) return idleLines; + + // 머리(0)와 몸통(1)은 그대로 유지 + final head = idleLines[0]; + final body = idleLines[1]; + + // 다리 애니메이션 (4프레임) - 걷기 동작 + const legFrames = [' /| ', ' |\\ ', ' /| ', ' |\\ ']; + final legs = legFrames[frameIndex % legFrames.length]; + + return [head, body, legs]; + } + /// 문자열 스프라이트를 AsciiCell 2D 배열로 변환 List> _spriteToCells(List lines) { return lines.map((line) { diff --git a/lib/src/core/model/equipment_item.dart b/lib/src/core/model/equipment_item.dart index 1649cc6..a612ff7 100644 --- a/lib/src/core/model/equipment_item.dart +++ b/lib/src/core/model/equipment_item.dart @@ -89,6 +89,41 @@ class EquipmentItem { ); } + /// JSON으로 직렬화 + Map toJson() { + return { + 'name': name, + 'slot': slot.name, + 'level': level, + 'weight': weight, + 'stats': stats.toJson(), + 'rarity': rarity.name, + }; + } + + /// JSON에서 역직렬화 + factory EquipmentItem.fromJson(Map json) { + final slotName = json['slot'] as String? ?? 'weapon'; + final rarityName = json['rarity'] as String? ?? 'common'; + + return EquipmentItem( + name: json['name'] as String? ?? '', + slot: EquipmentSlot.values.firstWhere( + (s) => s.name == slotName, + orElse: () => EquipmentSlot.weapon, + ), + level: json['level'] as int? ?? 0, + weight: json['weight'] as int? ?? 0, + stats: json['stats'] != null + ? ItemStats.fromJson(json['stats'] as Map) + : ItemStats.empty, + rarity: ItemRarity.values.firstWhere( + (r) => r.name == rarityName, + orElse: () => ItemRarity.common, + ), + ); + } + @override String toString() => name.isEmpty ? '(empty)' : name; } diff --git a/lib/src/core/model/hall_of_fame.dart b/lib/src/core/model/hall_of_fame.dart index 67d1efc..15d95b0 100644 --- a/lib/src/core/model/hall_of_fame.dart +++ b/lib/src/core/model/hall_of_fame.dart @@ -1,4 +1,5 @@ import 'package:askiineverdie/src/core/model/combat_stats.dart'; +import 'package:askiineverdie/src/core/model/equipment_item.dart'; import 'package:askiineverdie/src/core/model/game_state.dart'; /// 명예의 전당 엔트리 (Phase 10: Hall of Fame Entry) @@ -54,8 +55,8 @@ class HallOfFameEntry { /// 최종 전투 스탯 (향후 아스키 아레나용) final CombatStats? finalStats; - /// 최종 장비 목록 (향후 아스키 아레나용) - final Map? finalEquipment; + /// 최종 장비 목록 (풀 스탯 포함) + final List? finalEquipment; /// 최종 스펠북 (스펠 이름 + 랭크) final List>? finalSpells; @@ -98,19 +99,7 @@ class HallOfFameEntry { questsCompleted: state.progress.questCount, clearedAt: DateTime.now(), finalStats: combatStats, - finalEquipment: { - 'weapon': state.equipment.weapon, - 'shield': state.equipment.shield, - 'helm': state.equipment.helm, - 'hauberk': state.equipment.hauberk, - 'brassairts': state.equipment.brassairts, - 'vambraces': state.equipment.vambraces, - 'gauntlets': state.equipment.gauntlets, - 'gambeson': state.equipment.gambeson, - 'cuisses': state.equipment.cuisses, - 'greaves': state.equipment.greaves, - 'sollerets': state.equipment.sollerets, - }, + finalEquipment: List.from(state.equipment.items), finalSpells: state.spellBook.spells .map((s) => {'name': s.name, 'rank': s.rank}) .toList(), @@ -131,7 +120,7 @@ class HallOfFameEntry { 'questsCompleted': questsCompleted, 'clearedAt': clearedAt.toIso8601String(), 'finalStats': finalStats?.toJson(), - 'finalEquipment': finalEquipment, + 'finalEquipment': finalEquipment?.map((e) => e.toJson()).toList(), 'finalSpells': finalSpells, }; } @@ -153,7 +142,9 @@ class HallOfFameEntry { ? CombatStats.fromJson(json['finalStats'] as Map) : null, finalEquipment: json['finalEquipment'] != null - ? Map.from(json['finalEquipment'] as Map) + ? (json['finalEquipment'] as List) + .map((e) => EquipmentItem.fromJson(e as Map)) + .toList() : null, finalSpells: json['finalSpells'] != null ? (json['finalSpells'] as List) diff --git a/lib/src/core/model/item_stats.dart b/lib/src/core/model/item_stats.dart index f31a6b1..75eff1c 100644 --- a/lib/src/core/model/item_stats.dart +++ b/lib/src/core/model/item_stats.dart @@ -127,6 +127,52 @@ class ItemStats { /// 빈 스탯 (보너스 없음) static const empty = ItemStats(); + /// JSON으로 직렬화 + Map toJson() { + return { + 'atk': atk, + 'def': def, + 'magAtk': magAtk, + 'magDef': magDef, + 'criRate': criRate, + 'evasion': evasion, + 'blockRate': blockRate, + 'parryRate': parryRate, + 'hpBonus': hpBonus, + 'mpBonus': mpBonus, + 'strBonus': strBonus, + 'conBonus': conBonus, + 'dexBonus': dexBonus, + 'intBonus': intBonus, + 'wisBonus': wisBonus, + 'chaBonus': chaBonus, + 'attackSpeed': attackSpeed, + }; + } + + /// JSON에서 역직렬화 + factory ItemStats.fromJson(Map json) { + return ItemStats( + atk: json['atk'] as int? ?? 0, + def: json['def'] as int? ?? 0, + magAtk: json['magAtk'] as int? ?? 0, + magDef: json['magDef'] as int? ?? 0, + criRate: (json['criRate'] as num?)?.toDouble() ?? 0.0, + evasion: (json['evasion'] as num?)?.toDouble() ?? 0.0, + blockRate: (json['blockRate'] as num?)?.toDouble() ?? 0.0, + parryRate: (json['parryRate'] as num?)?.toDouble() ?? 0.0, + hpBonus: json['hpBonus'] as int? ?? 0, + mpBonus: json['mpBonus'] as int? ?? 0, + strBonus: json['strBonus'] as int? ?? 0, + conBonus: json['conBonus'] as int? ?? 0, + dexBonus: json['dexBonus'] as int? ?? 0, + intBonus: json['intBonus'] as int? ?? 0, + wisBonus: json['wisBonus'] as int? ?? 0, + chaBonus: json['chaBonus'] as int? ?? 0, + attackSpeed: json['attackSpeed'] as int? ?? 0, + ); + } + /// 두 스탯 합산 /// /// attackSpeed는 합산 대상 아님 (무기 슬롯 단일 값) diff --git a/lib/src/features/game/widgets/ascii_animation_card.dart b/lib/src/features/game/widgets/ascii_animation_card.dart index 8a0158f..37c00a8 100644 --- a/lib/src/features/game/widgets/ascii_animation_card.dart +++ b/lib/src/features/game/widgets/ascii_animation_card.dart @@ -93,8 +93,8 @@ class _AsciiAnimationCardState extends State { // Composer 인스턴스들 CanvasBattleComposer? _battleComposer; - final _walkingComposer = const CanvasWalkingComposer(); - final _townComposer = const CanvasTownComposer(); + CanvasWalkingComposer? _walkingComposer; + CanvasTownComposer? _townComposer; final _specialComposer = const CanvasSpecialComposer(); // 전투 애니메이션 상태 @@ -370,12 +370,15 @@ class _AsciiAnimationCardState extends State { case AsciiAnimationType.town: _animationMode = AnimationMode.town; + _townComposer = CanvasTownComposer(raceId: widget.raceId); case AsciiAnimationType.walking: _animationMode = AnimationMode.walking; + _walkingComposer = CanvasWalkingComposer(raceId: widget.raceId); default: _animationMode = AnimationMode.walking; + _walkingComposer = CanvasWalkingComposer(raceId: widget.raceId); } // 일시정지 상태면 타이머 시작하지 않음 @@ -445,8 +448,10 @@ class _AsciiAnimationCardState extends State { _globalTick, ) ?? [AsciiLayer.empty()], - AnimationMode.walking => _walkingComposer.composeLayers(_globalTick), - AnimationMode.town => _townComposer.composeLayers(_globalTick), + AnimationMode.walking => + _walkingComposer?.composeLayers(_globalTick) ?? [AsciiLayer.empty()], + AnimationMode.town => + _townComposer?.composeLayers(_globalTick) ?? [AsciiLayer.empty()], AnimationMode.special => _specialComposer.composeLayers( _currentSpecialAnimation ?? AsciiAnimationType.levelUp, _currentFrame, diff --git a/lib/src/features/hall_of_fame/hall_of_fame_screen.dart b/lib/src/features/hall_of_fame/hall_of_fame_screen.dart index 15d3e09..2510765 100644 --- a/lib/src/features/hall_of_fame/hall_of_fame_screen.dart +++ b/lib/src/features/hall_of_fame/hall_of_fame_screen.dart @@ -4,8 +4,13 @@ import 'package:flutter/material.dart'; import 'package:askiineverdie/data/game_text_l10n.dart' as l10n; import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart'; import 'package:askiineverdie/src/core/model/combat_stats.dart'; +import 'package:askiineverdie/src/core/model/equipment_item.dart'; +import 'package:askiineverdie/src/core/model/equipment_slot.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/hall_of_fame.dart'; +import 'package:askiineverdie/src/core/model/item_stats.dart'; import 'package:askiineverdie/src/core/storage/hall_of_fame_storage.dart'; +import 'package:askiineverdie/src/features/game/widgets/ascii_animation_card.dart'; /// 명예의 전당 화면 (Phase 10: Hall of Fame Screen) class HallOfFameScreen extends StatefulWidget { @@ -149,56 +154,160 @@ HallOfFameEntry _createDebugSampleEntry() { id: 'debug_sample_001', characterName: 'Debug Hero', race: 'byte_human', - klass: 'loop_wizard', + klass: 'recursion_master', level: 100, totalPlayTimeMs: 10 * 60 * 60 * 1000, // 10시간 totalDeaths: 3, monstersKilled: 1234, questsCompleted: 42, clearedAt: DateTime.now(), - finalEquipment: { - 'weapon': '+15 Legendary Debugger', - 'shield': '+10 Exception Shield', - 'helm': '+8 Null Pointer Helm', - 'hauberk': '+12 Thread-Safe Armor', - 'brassairts': '+6 Memory Guard', - 'vambraces': '+5 Stack Overflow Band', - 'gauntlets': '+7 Syntax Checker Gloves', - 'gambeson': '+9 Buffer Padding', - 'cuisses': '+4 Runtime Protector', - 'greaves': '+6 Compile Time Shin', - 'sollerets': '+5 Binary Boots', - }, - finalSpells: [ - {'name': 'Recursive Thunder', 'rank': 'XII'}, - {'name': 'Async Heal', 'rank': 'VIII'}, - {'name': 'Memory Leak Curse', 'rank': 'X'}, - {'name': 'Stack Overflow', 'rank': 'VI'}, - {'name': 'Null Pointer Strike', 'rank': 'IX'}, - {'name': 'Thread Lock', 'rank': 'VII'}, + finalEquipment: [ + // 무기: Universe Simulator|15, 레벨 100 → plus=85 + const EquipmentItem( + name: '+85 AI-Augmented Universe Simulator', + slot: EquipmentSlot.weapon, + level: 100, + weight: 15, + rarity: ItemRarity.legendary, + stats: ItemStats(atk: 180, magAtk: 120, criRate: 0.15, attackSpeed: 600), + ), + // 방패: Entropy Shield|65, 레벨 100 → plus=35 + const EquipmentItem( + name: '+35 Air-gapped Entropy Shield', + slot: EquipmentSlot.shield, + level: 100, + weight: 12, + rarity: ItemRarity.legendary, + stats: ItemStats(def: 85, magDef: 60, blockRate: 0.25), + ), + // 방어구: Multiverse Armor|60, 레벨 100 → plus=40 + const EquipmentItem( + name: '+40 Containerized Multiverse Armor', + slot: EquipmentSlot.helm, + level: 100, + weight: 8, + rarity: ItemRarity.legendary, + stats: ItemStats(def: 45, magDef: 55, intBonus: 5), + ), + // Singularity Barrier|55, 레벨 100 → plus=45 + const EquipmentItem( + name: '+45 Quantum-safe Singularity Barrier', + slot: EquipmentSlot.hauberk, + level: 100, + weight: 20, + rarity: ItemRarity.legendary, + stats: ItemStats(def: 95, magDef: 40, hpBonus: 200), + ), + // AI Firewall|45, 레벨 100 → plus=55 + const EquipmentItem( + name: '+55 Hardened AI Firewall', + slot: EquipmentSlot.brassairts, + level: 100, + weight: 6, + rarity: ItemRarity.epic, + stats: ItemStats(def: 35, magDef: 25), + ), + // Neural Network Mesh|40, 레벨 100 → plus=60 + const EquipmentItem( + name: '+60 Encrypted Neural Network Mesh', + slot: EquipmentSlot.vambraces, + level: 100, + weight: 5, + rarity: ItemRarity.epic, + stats: ItemStats(def: 30, magDef: 20, dexBonus: 3), + ), + // Zero-Day Aegis|30, 레벨 100 → plus=70 + const EquipmentItem( + name: '+70 Patched Zero-Day Aegis', + slot: EquipmentSlot.gauntlets, + level: 100, + weight: 4, + rarity: ItemRarity.epic, + stats: ItemStats(def: 25, atk: 15, criRate: 0.05), + ), + // Blockchain Platemail|35, 레벨 100 → plus=65 + const EquipmentItem( + name: '+65 Certified Blockchain Platemail', + slot: EquipmentSlot.gambeson, + level: 100, + weight: 10, + rarity: ItemRarity.epic, + stats: ItemStats(def: 40, magDef: 30), + ), + // Container Suit|19, 레벨 100 → plus=81 + const EquipmentItem( + name: '+81 Sandboxed Container Suit', + slot: EquipmentSlot.cuisses, + level: 100, + weight: 8, + rarity: ItemRarity.epic, + stats: ItemStats(def: 35, evasion: 0.05), + ), + // Virtualization Mail|20, 레벨 100 → plus=80 + const EquipmentItem( + name: '+80 Patched Virtualization Mail', + slot: EquipmentSlot.greaves, + level: 100, + weight: 7, + rarity: ItemRarity.epic, + stats: ItemStats(def: 30, dexBonus: 2), + ), + // Sandbox Shell|18, 레벨 100 → plus=82 + const EquipmentItem( + name: '+82 Hardened Sandbox Shell', + slot: EquipmentSlot.sollerets, + level: 100, + weight: 5, + rarity: ItemRarity.epic, + stats: ItemStats(def: 20, evasion: 0.03), + ), ], + // 레벨 100 캐릭터의 스펠: randomLow 분포로 낮은 인덱스 스펠이 높은 랭크 + // 100번의 레벨업 = 100번 스펠 학습 기회, 초반 스펠은 여러 번 학습되어 랭크 상승 + finalSpells: [ + {'name': 'Garbage Collection', 'rank': 'XIV'}, // 인덱스 0 - 가장 많이 학습 + {'name': 'Memory Optimization', 'rank': 'XII'}, // 인덱스 1 + {'name': 'Debug Mode', 'rank': 'XI'}, // 인덱스 2 + {'name': 'Breakpoint', 'rank': 'X'}, // 인덱스 3 + {'name': 'Step Over', 'rank': 'IX'}, // 인덱스 4 + {'name': 'Step Into', 'rank': 'VIII'}, // 인덱스 5 + {'name': 'Watch Variable', 'rank': 'VII'}, // 인덱스 6 + {'name': 'Hot Reload', 'rank': 'VI'}, // 인덱스 7 + {'name': 'Cold Boot', 'rank': 'V'}, // 인덱스 8 + {'name': 'Safe Mode', 'rank': 'IV'}, // 인덱스 9 + {'name': 'Kernel Panic', 'rank': 'III'}, // 인덱스 10 + {'name': 'Blue Screen', 'rank': 'II'}, // 인덱스 11 + {'name': 'Stack Trace', 'rank': 'I'}, // 인덱스 12 + ], + // 레벨 100 기본 스탯: 시작 ~60 + 99레벨 × 2스탯 = ~258 총합 + // recursion_master (마법사 계열): INT/WIS 집중 빌드 + // CombatStats는 게임 공식 CombatStats.fromStats()에 따라 계산 + // baseAtk = STR*2 + level + equipStats.atk = 32*2 + 100 + 195 = 359 + // baseDef = CON + level/2 + equipStats.def = 38 + 50 + 440 = 528 + // baseMagAtk = INT*2 + level + equipStats.magAtk = 65*2 + 100 + 120 = 350 + // baseMagDef = WIS + level/2 + equipStats.magDef = 55 + 50 + 210 = 315 finalStats: const CombatStats( - str: 85, - con: 72, - dex: 68, - intelligence: 90, - wis: 65, - cha: 55, - atk: 450, - def: 280, - magAtk: 520, - magDef: 195, - criRate: 0.35, - criDamage: 2.2, - evasion: 0.18, - accuracy: 0.95, - blockRate: 0.25, + str: 32, + con: 38, + dex: 45, + intelligence: 65, + wis: 55, + cha: 23, + atk: 359, + def: 528, + magAtk: 350, + magDef: 315, + criRate: 0.32, + criDamage: 1.95, + evasion: 0.30, + accuracy: 0.89, + blockRate: 0.37, parryRate: 0.15, attackDelayMs: 650, - hpMax: 2500, - hpCurrent: 2500, - mpMax: 1800, - mpCurrent: 1800, + hpMax: 1850, + hpCurrent: 1850, + mpMax: 2100, + mpCurrent: 2100, ), ); } @@ -208,55 +317,160 @@ HallOfFameEntry _createDebugSampleEntry2() { return HallOfFameEntry( id: 'debug_sample_002', characterName: 'Binary Knight', - race: 'pixel_elf', - klass: 'git_fighter', + race: 'null_elf', + klass: 'overflow_warrior', level: 95, totalPlayTimeMs: 8 * 60 * 60 * 1000 + 30 * 60 * 1000, // 8시간 30분 totalDeaths: 7, monstersKilled: 2156, questsCompleted: 38, clearedAt: DateTime.now().subtract(const Duration(days: 3)), - finalEquipment: { - 'weapon': '+12 Merge Conflict Sword', - 'shield': '+14 Firewall Buckler', - 'helm': '+10 SSH Helmet', - 'hauberk': '+11 Docker Container Plate', - 'brassairts': '+8 API Gateway Guard', - 'vambraces': '+7 Cache Hit Bracers', - 'gauntlets': '+9 Regex Gloves', - 'gambeson': '+6 JSON Parser Vest', - 'cuisses': '+8 Load Balancer Legs', - 'greaves': '+7 Kubernetes Greaves', - 'sollerets': '+6 Cloud Deploy Boots', - }, - finalSpells: [ - {'name': 'Fork Bomb', 'rank': 'X'}, - {'name': 'Garbage Collection', 'rank': 'IX'}, - {'name': 'Infinite Loop', 'rank': 'XI'}, - {'name': 'Buffer Overflow', 'rank': 'VII'}, + finalEquipment: [ + // 무기: Quantum Entangler|10, 레벨 95 → plus=85 + const EquipmentItem( + name: '+85 GPU-Powered Quantum Entangler', + slot: EquipmentSlot.weapon, + level: 95, + weight: 18, + rarity: ItemRarity.legendary, + stats: ItemStats(atk: 220, criRate: 0.12, parryRate: 0.08, attackSpeed: 850), + ), + // 방패: Multiverse Barrier|50, 레벨 95 → plus=45 + const EquipmentItem( + name: '+45 Air-gapped Multiverse Barrier', + slot: EquipmentSlot.shield, + level: 95, + weight: 16, + rarity: ItemRarity.legendary, + stats: ItemStats(def: 120, magDef: 45, blockRate: 0.35, hpBonus: 150), + ), + // Multiverse Armor|60, 레벨 95 → plus=35 + const EquipmentItem( + name: '+35 Certified Multiverse Armor', + slot: EquipmentSlot.helm, + level: 95, + weight: 10, + rarity: ItemRarity.legendary, + stats: ItemStats(def: 55, conBonus: 4), + ), + // Quantum Shield Matrix|50, 레벨 95 → plus=45 + const EquipmentItem( + name: '+45 Containerized Quantum Shield Matrix', + slot: EquipmentSlot.hauberk, + level: 95, + weight: 25, + rarity: ItemRarity.legendary, + stats: ItemStats(def: 110, hpBonus: 300, conBonus: 3), + ), + // ASLR Armor|17, 레벨 95 → plus=78 + const EquipmentItem( + name: '+78 Patched ASLR Armor', + slot: EquipmentSlot.brassairts, + level: 95, + weight: 8, + rarity: ItemRarity.epic, + stats: ItemStats(def: 40, strBonus: 2), + ), + // Stack Protector|15, 레벨 95 → plus=80 + const EquipmentItem( + name: '+80 Encrypted Stack Protector', + slot: EquipmentSlot.vambraces, + level: 95, + weight: 6, + rarity: ItemRarity.epic, + stats: ItemStats(def: 35, atk: 10), + ), + // Heap Guard|16, 레벨 95 → plus=79 + const EquipmentItem( + name: '+79 Hardened Heap Guard', + slot: EquipmentSlot.gauntlets, + level: 95, + weight: 5, + rarity: ItemRarity.epic, + stats: ItemStats(def: 30, atk: 25, criRate: 0.03), + ), + // Memory Barrier|14, 레벨 95 → plus=81 + const EquipmentItem( + name: '+81 Quantum-safe Memory Barrier', + slot: EquipmentSlot.gambeson, + level: 95, + weight: 12, + rarity: ItemRarity.epic, + stats: ItemStats(def: 45, magDef: 20), + ), + // Kernel Guard|12, 레벨 95 → plus=83 + const EquipmentItem( + name: '+83 Certified Kernel Guard', + slot: EquipmentSlot.cuisses, + level: 95, + weight: 10, + rarity: ItemRarity.epic, + stats: ItemStats(def: 42, strBonus: 2), + ), + // Protocol Suit|10, 레벨 95 → plus=85 + const EquipmentItem( + name: '+85 Sandboxed Protocol Suit', + slot: EquipmentSlot.greaves, + level: 95, + weight: 8, + rarity: ItemRarity.epic, + stats: ItemStats(def: 38, dexBonus: 2), + ), + // Encryption Layer|6, 레벨 95 → plus=89 + const EquipmentItem( + name: '+89 Patched Encryption Layer', + slot: EquipmentSlot.sollerets, + level: 95, + weight: 6, + rarity: ItemRarity.epic, + stats: ItemStats(def: 25, evasion: 0.02), + ), ], + // 레벨 95 캐릭터의 스펠: randomLow 분포 적용 + // 95번의 레벨업 기회, 전사 계열이라 WIS가 낮아 학습 가능 스펠 범위 제한 + // WIS 30 + level 95 = 125 → 대부분의 스펠 접근 가능 + finalSpells: [ + {'name': 'Garbage Collection', 'rank': 'XIII'}, // 인덱스 0 + {'name': 'Memory Optimization', 'rank': 'XI'}, // 인덱스 1 + {'name': 'Debug Mode', 'rank': 'X'}, // 인덱스 2 + {'name': 'Breakpoint', 'rank': 'IX'}, // 인덱스 3 + {'name': 'Step Over', 'rank': 'VIII'}, // 인덱스 4 + {'name': 'Step Into', 'rank': 'VII'}, // 인덱스 5 + {'name': 'Watch Variable', 'rank': 'VI'}, // 인덱스 6 + {'name': 'Hot Reload', 'rank': 'V'}, // 인덱스 7 + {'name': 'Cold Boot', 'rank': 'IV'}, // 인덱스 8 + {'name': 'Safe Mode', 'rank': 'III'}, // 인덱스 9 + {'name': 'Kernel Panic', 'rank': 'II'}, // 인덱스 10 + ], + // 레벨 95 기본 스탯: 시작 ~60 + 94레벨 × 2스탯 = ~248 총합 + // overflow_warrior (전사 계열): STR/CON 집중 빌드 + // CombatStats는 게임 공식에 따라 계산 + // baseAtk = STR*2 + level + equipStats.atk = 58*2 + 95 + 255 = 466 + // baseDef = CON + level/2 + equipStats.def = 55 + 47 + 577 = 679 + // baseMagAtk = INT*2 + level + equipStats.magAtk = 28*2 + 95 + 0 = 151 + // baseMagDef = WIS + level/2 + equipStats.magDef = 30 + 47 + 65 = 142 finalStats: const CombatStats( - str: 95, - con: 88, - dex: 75, - intelligence: 60, - wis: 55, - cha: 50, - atk: 580, - def: 420, - magAtk: 280, - magDef: 165, - criRate: 0.28, - criDamage: 2.5, - evasion: 0.12, - accuracy: 0.92, - blockRate: 0.35, - parryRate: 0.22, + str: 58, + con: 55, + dex: 42, + intelligence: 28, + wis: 30, + cha: 35, + atk: 466, + def: 679, + magAtk: 151, + magDef: 142, + criRate: 0.26, + criDamage: 1.92, + evasion: 0.23, + accuracy: 0.88, + blockRate: 0.47, + parryRate: 0.28, attackDelayMs: 800, - hpMax: 3200, - hpCurrent: 3200, - mpMax: 1200, - mpCurrent: 1200, + hpMax: 2200, + hpCurrent: 2200, + mpMax: 1100, + mpCurrent: 1100, ), ); } @@ -596,13 +810,20 @@ class _HallOfFameDetailDialog extends StatelessWidget { ), ], ), - content: SizedBox( - width: double.maxFinite, + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 500), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + // 캐릭터 애니메이션 섹션 (Character Animation Section) + _buildSection( + icon: Icons.movie, + title: l10n.hofCharacterPreview, + child: _buildAnimationPreview(), + ), + const SizedBox(height: 16), // 통계 섹션 (Statistics Section) _buildSection( icon: Icons.analytics, @@ -676,6 +897,47 @@ class _HallOfFameDetailDialog extends StatelessWidget { ); } + /// 캐릭터 애니메이션 미리보기 위젯 + Widget _buildAnimationPreview() { + // 장비에서 무기와 방패 이름 추출 + String? weaponName; + String? shieldName; + + if (entry.finalEquipment != null) { + for (final item in entry.finalEquipment!) { + if (item.slot == EquipmentSlot.weapon && item.isNotEmpty) { + weaponName = item.name; + } else if (item.slot == EquipmentSlot.shield && item.isNotEmpty) { + shieldName = item.name; + } + } + } + + // AsciiCanvasWidget은 60x8 그리드 사용 + // 다이얼로그 내에서 적절한 크기: 360x80 (비율 7.5:1 유지) + return Center( + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.amber.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: SizedBox( + width: 360, + height: 80, + child: AsciiAnimationCard( + taskType: TaskType.kill, // 전투 애니메이션 표시 + raceId: entry.race, + weaponName: weaponName, + shieldName: shieldName, + characterLevel: entry.level, + monsterLevel: entry.level, // 레벨에 맞는 몬스터 크기 + monsterBaseName: 'Glitch God', // 최종 보스 + ), + ), + ), + ); + } + Widget _buildStatsGrid() { return Wrap( spacing: 16, @@ -806,27 +1068,29 @@ class _HallOfFameDetailDialog extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: Colors.grey.shade100, + color: Colors.amber.shade700.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amber.shade700.withValues(alpha: 0.3)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 14, color: Colors.grey.shade600), + Icon(icon, size: 14, color: Colors.amber.shade600), const SizedBox(width: 6), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( value, - style: const TextStyle( + style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13, + color: Colors.amber.shade300, ), ), Text( label, - style: TextStyle(fontSize: 10, color: Colors.grey.shade600), + style: TextStyle(fontSize: 10, color: Colors.amber.shade400), ), ], ), @@ -837,56 +1101,221 @@ class _HallOfFameDetailDialog extends StatelessWidget { Widget _buildEquipmentList(BuildContext context) { final equipment = entry.finalEquipment!; - // 슬롯 키, 아이콘, l10n 슬롯 이름 - final slots = [ - ('weapon', Icons.gavel, l10n.slotWeapon, 0), - ('shield', Icons.shield, l10n.slotShield, 1), - ('helm', Icons.sports_mma, l10n.slotHelm, 2), - ('hauberk', Icons.checkroom, l10n.slotHauberk, 2), - ('brassairts', Icons.front_hand, l10n.slotBrassairts, 2), - ('vambraces', Icons.back_hand, l10n.slotVambraces, 2), - ('gauntlets', Icons.sports_handball, l10n.slotGauntlets, 2), - ('gambeson', Icons.dry_cleaning, l10n.slotGambeson, 2), - ('cuisses', Icons.airline_seat_legroom_normal, l10n.slotCuisses, 2), - ('greaves', Icons.snowshoeing, l10n.slotGreaves, 2), - ('sollerets', Icons.do_not_step, l10n.slotSollerets, 2), - ]; return Column( - children: slots.map((slot) { - final (key, icon, label, slotIndex) = slot; - final rawValue = equipment[key] ?? ''; - // 장비 이름 번역 적용 - final value = rawValue.isEmpty - ? l10n.uiEmpty - : GameDataL10n.translateEquipString(context, rawValue, slotIndex); + children: equipment.map((item) { + if (item.isEmpty) return const SizedBox.shrink(); + + final slotLabel = _getSlotLabel(item.slot); + final slotIcon = _getSlotIcon(item.slot); + final slotIndex = _getSlotIndex(item.slot); + final rarityColor = _getRarityColor(item.rarity); + + // 장비 이름 번역 + final translatedName = GameDataL10n.translateEquipString( + context, + item.name, + slotIndex, + ); + + // 주요 스탯 요약 + final statSummary = _buildStatSummary(item.stats, item.slot); + return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - Icon(icon, size: 16, color: Colors.grey.shade500), - const SizedBox(width: 8), - SizedBox( - width: 80, - child: Text( - label, - style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + padding: const EdgeInsets.symmetric(vertical: 4), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: rarityColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: rarityColor.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 슬롯 + 이름 + 희귀도 표시 + Row( + children: [ + Icon(slotIcon, size: 14, color: rarityColor), + const SizedBox(width: 6), + Text( + slotLabel, + style: TextStyle( + fontSize: 10, + color: Colors.grey.shade500, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, + ), + decoration: BoxDecoration( + color: rarityColor.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _getRarityLabel(item.rarity), + style: TextStyle( + fontSize: 9, + color: rarityColor, + fontWeight: FontWeight.bold, + ), + ), + ), + ], ), - ), - Expanded( - child: Text( - value, - style: const TextStyle(fontSize: 12), - overflow: TextOverflow.ellipsis, + const SizedBox(height: 4), + // 장비 이름 + Text( + translatedName, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: rarityColor, + ), ), - ), - ], + // 스탯 요약 + if (statSummary.isNotEmpty) ...[ + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 2, + children: statSummary, + ), + ], + ], + ), ), ); }).toList(), ); } + String _getSlotLabel(EquipmentSlot slot) { + return switch (slot) { + EquipmentSlot.weapon => l10n.slotWeapon, + EquipmentSlot.shield => l10n.slotShield, + EquipmentSlot.helm => l10n.slotHelm, + EquipmentSlot.hauberk => l10n.slotHauberk, + EquipmentSlot.brassairts => l10n.slotBrassairts, + EquipmentSlot.vambraces => l10n.slotVambraces, + EquipmentSlot.gauntlets => l10n.slotGauntlets, + EquipmentSlot.gambeson => l10n.slotGambeson, + EquipmentSlot.cuisses => l10n.slotCuisses, + EquipmentSlot.greaves => l10n.slotGreaves, + EquipmentSlot.sollerets => l10n.slotSollerets, + }; + } + + IconData _getSlotIcon(EquipmentSlot slot) { + return switch (slot) { + EquipmentSlot.weapon => Icons.gavel, + EquipmentSlot.shield => Icons.shield, + EquipmentSlot.helm => Icons.sports_mma, + EquipmentSlot.hauberk => Icons.checkroom, + EquipmentSlot.brassairts => Icons.front_hand, + EquipmentSlot.vambraces => Icons.back_hand, + EquipmentSlot.gauntlets => Icons.sports_handball, + EquipmentSlot.gambeson => Icons.dry_cleaning, + EquipmentSlot.cuisses => Icons.airline_seat_legroom_normal, + EquipmentSlot.greaves => Icons.snowshoeing, + EquipmentSlot.sollerets => Icons.do_not_step, + }; + } + + int _getSlotIndex(EquipmentSlot slot) { + return switch (slot) { + EquipmentSlot.weapon => 0, + EquipmentSlot.shield => 1, + _ => 2, + }; + } + + Color _getRarityColor(ItemRarity rarity) { + return switch (rarity) { + ItemRarity.common => Colors.grey.shade600, + ItemRarity.uncommon => Colors.green.shade600, + ItemRarity.rare => Colors.blue.shade600, + ItemRarity.epic => Colors.purple.shade600, + ItemRarity.legendary => Colors.orange.shade700, + }; + } + + String _getRarityLabel(ItemRarity rarity) { + return switch (rarity) { + ItemRarity.common => l10n.rarityCommon, + ItemRarity.uncommon => l10n.rarityUncommon, + ItemRarity.rare => l10n.rarityRare, + ItemRarity.epic => l10n.rarityEpic, + ItemRarity.legendary => l10n.rarityLegendary, + }; + } + + List _buildStatSummary(ItemStats stats, EquipmentSlot slot) { + final widgets = []; + + void addStat(String label, String value, Color color) { + widgets.add( + Text( + '$label $value', + style: TextStyle(fontSize: 10, color: color), + ), + ); + } + + // 공격 스탯 + if (stats.atk > 0) addStat(l10n.statAtk, '+${stats.atk}', Colors.red); + if (stats.magAtk > 0) { + addStat(l10n.statMAtk, '+${stats.magAtk}', Colors.blue); + } + + // 방어 스탯 + if (stats.def > 0) addStat(l10n.statDef, '+${stats.def}', Colors.brown); + if (stats.magDef > 0) { + addStat(l10n.statMDef, '+${stats.magDef}', Colors.indigo); + } + + // 확률 스탯 + if (stats.criRate > 0) { + addStat(l10n.statCri, '+${(stats.criRate * 100).toStringAsFixed(0)}%', Colors.amber); + } + if (stats.blockRate > 0) { + addStat(l10n.statBlock, '+${(stats.blockRate * 100).toStringAsFixed(0)}%', Colors.blueGrey); + } + if (stats.evasion > 0) { + addStat(l10n.statEva, '+${(stats.evasion * 100).toStringAsFixed(0)}%', Colors.teal); + } + if (stats.parryRate > 0) { + addStat(l10n.statParry, '+${(stats.parryRate * 100).toStringAsFixed(0)}%', Colors.cyan); + } + + // 보너스 스탯 + if (stats.hpBonus > 0) { + addStat(l10n.statHp, '+${stats.hpBonus}', Colors.red.shade400); + } + if (stats.mpBonus > 0) { + addStat(l10n.statMp, '+${stats.mpBonus}', Colors.blue.shade400); + } + + // 능력치 보너스 + if (stats.strBonus > 0) { + addStat(l10n.statStr, '+${stats.strBonus}', Colors.red.shade700); + } + if (stats.conBonus > 0) { + addStat(l10n.statCon, '+${stats.conBonus}', Colors.orange.shade700); + } + if (stats.dexBonus > 0) { + addStat(l10n.statDex, '+${stats.dexBonus}', Colors.green.shade700); + } + if (stats.intBonus > 0) { + addStat(l10n.statInt, '+${stats.intBonus}', Colors.blue.shade700); + } + + return widgets; + } + Widget _buildSpellList(BuildContext context) { final spells = entry.finalSpells!; return Wrap( diff --git a/lib/src/features/new_character/new_character_screen.dart b/lib/src/features/new_character/new_character_screen.dart index 3fba2f8..a1de027 100644 --- a/lib/src/features/new_character/new_character_screen.dart +++ b/lib/src/features/new_character/new_character_screen.dart @@ -132,6 +132,7 @@ class _NewCharacterScreenState extends State { } /// Re-Roll 버튼 클릭 + /// 원본 NewGuy.pas RerollClick: 스탯, 종족, 클래스 모두 랜덤화 void _onReroll() { // 현재 시드를 이력에 저장 _rollHistory.insert(0, _currentSeed); @@ -142,7 +143,15 @@ class _NewCharacterScreenState extends State { } // 새 시드로 굴림 - _currentSeed = math.Random().nextInt(0x7FFFFFFF); + final random = math.Random(); + _currentSeed = random.nextInt(0x7FFFFFFF); + + // 종족/클래스도 랜덤 선택 + setState(() { + _selectedRaceIndex = random.nextInt(_races.length); + _selectedKlassIndex = random.nextInt(_klasses.length); + }); + _rollStats(); // 선택된 종족/직업으로 스크롤