feat(hall-of-fame): 명예의 전당 대폭 개선 및 장비/아이템 직렬화
- HallOfFameEntry에 finalEquipmentDetails 추가 (상세 장비 정보) - EquipmentItem/ItemStats에 toJson/fromJson 직렬화 추가 - 명예의 전당 상세 다이얼로그 UI 대폭 개선 - Canvas 타운/워킹 애니메이션 컴포저 개선 - 캐릭터 생성 화면 UI 개선 - 게임 텍스트 다국어 지원 확장
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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는 합산 대상 아님 (무기 슬롯 단일 값)
|
||||
|
||||
Reference in New Issue
Block a user