feat(hall-of-fame): 명예의 전당 대폭 개선 및 장비/아이템 직렬화

- HallOfFameEntry에 finalEquipmentDetails 추가 (상세 장비 정보)
- EquipmentItem/ItemStats에 toJson/fromJson 직렬화 추가
- 명예의 전당 상세 다이얼로그 UI 대폭 개선
- Canvas 타운/워킹 애니메이션 컴포저 개선
- 캐릭터 생성 화면 UI 개선
- 게임 텍스트 다국어 지원 확장
This commit is contained in:
JiWoong Sul
2025-12-24 18:34:00 +09:00
parent d82bf05978
commit dd83923ddf
9 changed files with 730 additions and 153 deletions

View File

@@ -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<String> 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);

View File

@@ -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<String> 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<String> _animateWalking(List<String> 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<List<AsciiCell>> _spriteToCells(List<String> lines) {
return lines.map((line) {

View File

@@ -89,6 +89,41 @@ class EquipmentItem {
);
}
/// JSON으로 직렬화
Map<String, dynamic> toJson() {
return {
'name': name,
'slot': slot.name,
'level': level,
'weight': weight,
'stats': stats.toJson(),
'rarity': rarity.name,
};
}
/// JSON에서 역직렬화
factory EquipmentItem.fromJson(Map<String, dynamic> 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<String, dynamic>)
: ItemStats.empty,
rarity: ItemRarity.values.firstWhere(
(r) => r.name == rarityName,
orElse: () => ItemRarity.common,
),
);
}
@override
String toString() => name.isEmpty ? '(empty)' : name;
}

View File

@@ -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<String, String>? finalEquipment;
/// 최종 장비 목록 (풀 스탯 포함)
final List<EquipmentItem>? finalEquipment;
/// 최종 스펠북 (스펠 이름 + 랭크)
final List<Map<String, String>>? 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<EquipmentItem>.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<String, dynamic>)
: null,
finalEquipment: json['finalEquipment'] != null
? Map<String, String>.from(json['finalEquipment'] as Map)
? (json['finalEquipment'] as List<dynamic>)
.map((e) => EquipmentItem.fromJson(e as Map<String, dynamic>))
.toList()
: null,
finalSpells: json['finalSpells'] != null
? (json['finalSpells'] as List<dynamic>)

View File

@@ -127,6 +127,52 @@ class ItemStats {
/// 빈 스탯 (보너스 없음)
static const empty = ItemStats();
/// JSON으로 직렬화
Map<String, dynamic> 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<String, dynamic> 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는 합산 대상 아님 (무기 슬롯 단일 값)