Compare commits
9 Commits
8fcb7bf2b7
...
c56e76b176
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c56e76b176 | ||
|
|
dadd25837d | ||
|
|
e13e8032d9 | ||
|
|
864a866039 | ||
|
|
6ddbf23816 | ||
|
|
1a8858a3b1 | ||
|
|
faaa5af54e | ||
|
|
68284323c8 | ||
|
|
8f351df0b6 |
80
CLAUDE.md
80
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
Askii Never Die는 Progress Quest 6.4 (Delphi 원본)를 Flutter로 100% 동일하게 복제하는 오프라인 싱글플레이어 RPG입니다. 네트워크 기능은 모두 제외되며, 원본 알고리즘과 데이터를 그대로 유지해야 합니다.
|
||||
Askii Never Die는 Progress Quest 6.4의 핵심 메커니즘을 기반으로, 독자적 세계관("디지털 판타지")과 확장된 시스템으로 재구성한 오프라인 싱글플레이어 방치형 RPG입니다. 네트워크 기능은 제외됩니다.
|
||||
|
||||
## 빌드 및 실행
|
||||
|
||||
@@ -27,29 +27,53 @@ flutter test
|
||||
|
||||
```
|
||||
lib/
|
||||
├── main.dart # 앱 진입점
|
||||
├── data/pq_config_data.dart # PQ 정적 데이터 (Config.dfm 추출)
|
||||
├── main.dart # 앱 진입점
|
||||
├── data/ # 정적 데이터 (Config.dfm 추출 + 확장)
|
||||
│ ├── pq_config_data.dart # PQ 원본 정적 데이터
|
||||
│ ├── class_data.dart # 직업 데이터
|
||||
│ ├── race_data.dart # 종족 데이터
|
||||
│ ├── skill_data.dart # 스킬 데이터
|
||||
│ ├── potion_data.dart # 포션 데이터
|
||||
│ ├── story_data.dart # 스토리 데이터
|
||||
│ └── game_text_l10n.dart # 게임 텍스트 번역
|
||||
├── l10n/ # 앱 UI 다국어 리소스 (arb)
|
||||
└── src/
|
||||
├── app.dart # MaterialApp 설정
|
||||
├── app.dart # MaterialApp 설정
|
||||
├── core/
|
||||
│ ├── engine/ # 게임 루프 및 진행 로직
|
||||
│ │ ├── progress_loop.dart # 타이머 기반 메인 루프 (원본 200ms)
|
||||
│ │ ├── progress_service.dart # 틱 처리, 경험치/레벨업 로직
|
||||
│ │ ├── game_mutations.dart # 상태 변경 함수
|
||||
│ │ └── reward_service.dart # 보상 처리
|
||||
│ ├── model/
|
||||
│ │ ├── game_state.dart # 핵심 상태: Traits, Stats, Inventory, Equipment, SpellBook, ProgressState, QueueState
|
||||
│ │ ├── pq_config.dart # Config 데이터 접근
|
||||
│ │ ├── equipment_slot.dart # 장비 슬롯 정의
|
||||
│ │ └── save_data.dart # 저장 데이터 구조
|
||||
│ ├── storage/ # 세이브 파일 처리
|
||||
│ └── util/
|
||||
│ ├── deterministic_random.dart # 결정론적 RNG (재현 가능)
|
||||
│ ├── pq_logic.dart # 원본 로직 포팅 (odds, randSign 등)
|
||||
│ └── roman.dart # 로마 숫자 변환
|
||||
└── features/
|
||||
├── front/front_screen.dart # 임시 프론트 화면
|
||||
└── game/game_session_controller.dart # 게임 세션 관리
|
||||
│ ├── engine/ # 게임 루프 및 진행 로직
|
||||
│ │ ├── progress_loop.dart # 타이머 기반 메인 루프
|
||||
│ │ ├── progress_service.dart # 틱 처리, 경험치/레벨업 로직
|
||||
│ │ ├── game_mutations.dart # 상태 변경 함수
|
||||
│ │ ├── reward_service.dart # 보상 처리
|
||||
│ │ ├── combat_calculator.dart # 전투 계산
|
||||
│ │ ├── combat_tick_service.dart # 전투 틱 처리
|
||||
│ │ ├── arena_service.dart # 아레나 시스템
|
||||
│ │ ├── skill_service.dart # 스킬 시스템
|
||||
│ │ ├── item_service.dart # 아이템 처리
|
||||
│ │ ├── potion_service.dart # 포션 시스템
|
||||
│ │ ├── shop_service.dart # 상점 시스템
|
||||
│ │ ├── story_service.dart # 스토리 진행
|
||||
│ │ └── ... # 기타 서비스
|
||||
│ ├── model/ # 게임 상태 및 데이터 모델
|
||||
│ ├── animation/ # ASCII 애니메이션 데이터/렌더링
|
||||
│ ├── audio/ # 오디오 서비스
|
||||
│ ├── storage/ # 세이브/설정 저장소
|
||||
│ ├── notification/ # 알림 서비스
|
||||
│ ├── constants/ # 상수 정의
|
||||
│ ├── l10n/ # 게임 데이터 번역 유틸
|
||||
│ └── util/ # 유틸리티 (RNG, 로직 헬퍼 등)
|
||||
├── features/
|
||||
│ ├── front/ # 타이틀/세이브 선택 화면
|
||||
│ ├── new_character/ # 캐릭터 생성 화면
|
||||
│ ├── game/ # 게임 진행 화면 (메인)
|
||||
│ │ ├── controllers/ # 전투 로그, 오디오 컨트롤러
|
||||
│ │ ├── managers/ # 통계, 부활, 속도 부스트 등
|
||||
│ │ ├── pages/ # 탭별 페이지 (장비, 인벤토리, 퀘스트 등)
|
||||
│ │ └── widgets/ # UI 위젯
|
||||
│ ├── arena/ # 아레나 전투 화면
|
||||
│ ├── hall_of_fame/ # 명예의 전당
|
||||
│ └── settings/ # 설정 화면
|
||||
└── shared/ # 공통 테마/위젯
|
||||
|
||||
example/pq/ # Delphi 원본 소스 (참조용, 빌드 대상 아님)
|
||||
test/ # 단위/위젯 테스트
|
||||
@@ -69,10 +93,9 @@ test/ # 단위/위젯 테스트
|
||||
|
||||
## 핵심 규칙
|
||||
|
||||
### 원본 충실도
|
||||
- `example/pq/` 내 Delphi 소스의 알고리즘/데이터를 100% 동일하게 포팅
|
||||
- 원본 로직 변경 필요 시 반드시 사용자 승인 필요
|
||||
- 새로운 기능, 값, 처리 로직 추가 금지 (디버깅 로그 예외)
|
||||
### 원본 참조 정책
|
||||
- `example/pq/`는 참조용으로 유지
|
||||
- 원본 알고리즘은 참고하되 독자적 확장/수정 허용
|
||||
|
||||
### 데이터 관리
|
||||
- 정적 데이터(몬스터, 아이템, 주문 등)는 `Config.dfm`에서 추출하여 JSON/Dart const로 관리
|
||||
@@ -87,11 +110,13 @@ test/ # 단위/위젯 테스트
|
||||
- SRP(Single Responsibility Principle) 준수
|
||||
|
||||
### 화면 구성
|
||||
- 2개 화면만 사용: 캐릭터 생성 화면, 게임 진행 화면
|
||||
- 주요 화면: 프론트, 캐릭터 생성, 게임 진행, 아레나, 명예의 전당, 설정
|
||||
- 화면 내 요소는 위젯 단위로 분리
|
||||
|
||||
## 원본 소스 참조 (example/pq/)
|
||||
|
||||
> 참고용으로만 사용. 원본 로직을 그대로 따를 의무는 없음.
|
||||
|
||||
| 파일 | 핵심 함수/라인 | 역할 |
|
||||
|------|----------------|------|
|
||||
| `Main.pas:523-1040` | `MonsterTask` | 전투/전리품/레벨업 |
|
||||
@@ -105,7 +130,6 @@ test/ # 단위/위젯 테스트
|
||||
- `pubspec.yaml` 의존성 변경
|
||||
- 플랫폼 빌드 설정 (Android/iOS/desktop)
|
||||
- 네트워크 접근 도입
|
||||
- 원본 데이터/알고리즘 수정
|
||||
- 대규모 파일 삭제 또는 구조 변경
|
||||
|
||||
## 커밋 규칙
|
||||
|
||||
@@ -48,6 +48,12 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
|
||||
21
android/app/proguard-rules.pro
vendored
Normal file
21
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Flutter 기본 규칙
|
||||
-keep class io.flutter.app.** { *; }
|
||||
-keep class io.flutter.plugin.** { *; }
|
||||
-keep class io.flutter.util.** { *; }
|
||||
-keep class io.flutter.view.** { *; }
|
||||
-keep class io.flutter.** { *; }
|
||||
-keep class io.flutter.plugins.** { *; }
|
||||
|
||||
# Google Mobile Ads (AdMob)
|
||||
-keep class com.google.android.gms.ads.** { *; }
|
||||
-keep class com.google.ads.** { *; }
|
||||
|
||||
# In-App Purchase (Google Play Billing)
|
||||
-keep class com.android.vending.billing.** { *; }
|
||||
|
||||
# Kotlin 직렬화(serialization) 관련
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes InnerClasses
|
||||
|
||||
# 제네릭(generics) 시그니처 유지
|
||||
-keepattributes Signature
|
||||
@@ -1,9 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- IAP 결제 권한 -->
|
||||
<!-- AdMob 광고 로드에 필요 -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- IAP 결제(billing) 권한 -->
|
||||
<uses-permission android:name="com.android.vending.BILLING" />
|
||||
|
||||
<application
|
||||
android:label="asciineverdie"
|
||||
android:label="ASCII Never Die"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<!-- Copyright Protection -->
|
||||
|
||||
@@ -11,14 +11,18 @@
|
||||
| 영역 | 점수 | CRITICAL | HIGH | MEDIUM | LOW |
|
||||
|------|------|----------|------|--------|-----|
|
||||
| 보안 | **8/10** | - | - | 1 | - |
|
||||
| 출시 준비 | **4/10** | 4 | 4 | 5 | - |
|
||||
| 사업/수익화 | **4/10** | 5 | 1 | 1 | 1 |
|
||||
| 코드 품질 | **7/10** | - | 3 | 3 | 1 |
|
||||
| 빌드/테스트 | **7/10** | - | 1 | 2 | - |
|
||||
| 로컬라이제이션 | **5/10** | 4 | 3 | 4 | - |
|
||||
| 원본 충실도 | **특수** | 1 | - | - | - |
|
||||
| 출시 준비 | **9/10** | ~~4~~ → 0 | ~~4~~ → 0 | 5 | - |
|
||||
| 사업/수익화 | **6/10** | ~~5~~ → 3 | 1 | 1 | 1 |
|
||||
| 코드 품질 | **8/10** | - | ~~3~~ → 1 | ~~3~~ → 1 | ~~1~~ → 0 |
|
||||
| 빌드/테스트 | **9/10** | - | ~~1~~ → 0 | 2 | - |
|
||||
| 로컬라이제이션 | **8/10** | ~~4~~ → 0 | ~~3~~ → 1 | 4 | - |
|
||||
| 원본 충실도 | **해결됨** | ~~1~~ → 0 | - | - | - |
|
||||
|
||||
**종합 판정: 출시 불가 상태. CRITICAL 이슈 15건 해결 필요.**
|
||||
**종합 판정: CRITICAL 이슈 ~~15건~~ → 3건 잔여 (모두 외부 콘솔 작업). 코드 작업 가능 항목 대부분 해결 완료.**
|
||||
|
||||
> **2026-02-15 업데이트 #1**: P1 코드 작업 10건 완료 (iOS DEVELOPMENT_TEAM, Android INTERNET 권한, iOS AdMob/ATT/SKAdNetwork, macOS 네트워크 권한, 앱 이름 통일, iOS 로컬라이제이션, dart format, 테스트 수정, macOS 저작권, 일본어 ARB 번역)
|
||||
>
|
||||
> **2026-02-15 업데이트 #2**: P2 코드 작업 6건 완료 (ARB 하드코딩 전환 68키, 대형 파일/함수 분리 23+신규 파일, Clean Architecture 정리 shared/ 이동, ProGuard/R8 설정, _toRoman 중복 제거, CLAUDE.md 현행화)
|
||||
|
||||
---
|
||||
|
||||
@@ -50,7 +54,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 2. 출시 준비 상태 - 7개 CRITICAL
|
||||
## 2. 출시 준비 상태 - ~~7개~~ 0개 CRITICAL (모두 해결)
|
||||
|
||||
### 2.1 CRITICAL (출시 차단)
|
||||
|
||||
@@ -58,20 +62,20 @@
|
||||
|---|------|------|
|
||||
| ~~R1~~ | ~~iOS Bundle ID = `com.example.asciineverdie`~~ | **수정 완료** - `com.naturebridgeai.asciineverdie`로 변경됨 |
|
||||
| ~~R2~~ | ~~macOS Bundle ID = `com.example.asciineverdie`~~ | **수정 완료** - `com.naturebridgeai.asciineverdie`로 변경됨 |
|
||||
| R3 | **iOS DEVELOPMENT_TEAM 미설정** | 서명 불가, Xcode에서 Team ID 설정 필요 |
|
||||
| ~~R3~~ | ~~iOS DEVELOPMENT_TEAM 미설정~~ | **수정 완료** - `DEVELOPMENT_TEAM = 82SY27V867` (Debug/Release/Profile) |
|
||||
| ~~R4~~ | ~~정치적 문구가 iOS/Android 메타데이터에 포함~~ | **의도적 포함** - 소유자 확인 완료. 앱스토어 심사 시 거부 가능성 인지 |
|
||||
| R5 | **Android 릴리즈에 INTERNET 권한 누락** | AdMob이 릴리즈 빌드에서 동작 불가 (debug/profile에만 존재) |
|
||||
| R6 | **iOS `GADApplicationIdentifier` 누락** | AdMob 초기화 시 iOS 앱 크래시 |
|
||||
| ~~R5~~ | ~~Android 릴리즈에 INTERNET 권한 누락~~ | **수정 완료** - `AndroidManifest.xml`(main)에 INTERNET 권한 추가 |
|
||||
| ~~R6~~ | ~~iOS `GADApplicationIdentifier` 누락~~ | **수정 완료** - `Info.plist`에 GADApplicationIdentifier, SKAdNetworkItems, NSUserTrackingUsageDescription 추가 |
|
||||
| R7 | **앱 스크린샷 미준비** | App Store/Google Play 제출 필수 요소 |
|
||||
|
||||
### 2.2 HIGH (출시 전 수정 권장)
|
||||
|
||||
| # | 이슈 | 상세 |
|
||||
|---|------|------|
|
||||
| R8 | 앱 이름 플랫폼별 불일치 | Android: `asciineverdie`, iOS: `Asciineverdie`, 마케팅: `ASCII Never Die` |
|
||||
| R9 | macOS Release entitlements에 네트워크 권한 없음 | AdMob 동작 불가 |
|
||||
| R10 | Android ProGuard/R8 미설정 | 코드 난독화 미적용 |
|
||||
| R11 | macOS PRODUCT_COPYRIGHT = `Copyright 2025 com.example` | 기본값 미수정 |
|
||||
| ~~R8~~ | ~~앱 이름 플랫폼별 불일치~~ | **수정 완료** - 전 플랫폼 `ASCII Never Die`로 통일 |
|
||||
| ~~R9~~ | ~~macOS Release entitlements에 네트워크 권한 없음~~ | **수정 완료** - `com.apple.security.network.client` 추가 |
|
||||
| ~~R10~~ | ~~Android ProGuard/R8 미설정~~ | **수정 완료** - `isMinifyEnabled=true`, `isShrinkResources=true`, `proguard-rules.pro` 추가 |
|
||||
| ~~R11~~ | ~~macOS PRODUCT_COPYRIGHT = `Copyright 2025 com.example`~~ | **수정 완료** - `Copyright © 2025 naturebridgeai`로 변경 |
|
||||
|
||||
### 2.3 MEDIUM
|
||||
|
||||
@@ -87,9 +91,13 @@
|
||||
|
||||
| 항목 | 설정값 | 상태 |
|
||||
|------|--------|------|
|
||||
| CFBundleDisplayName | `Asciineverdie` | 수정 필요 |
|
||||
| CFBundleDisplayName | `ASCII Never Die` | **수정 완료** |
|
||||
| PRODUCT_BUNDLE_IDENTIFIER | `com.naturebridgeai.asciineverdie` | **수정 완료** |
|
||||
| DEVELOPMENT_TEAM | 미설정 | **CRITICAL** |
|
||||
| DEVELOPMENT_TEAM | `82SY27V867` | **수정 완료** |
|
||||
| GADApplicationIdentifier | `ca-app-pub-6691216385521068~8216990571` | **수정 완료** |
|
||||
| SKAdNetworkItems | Google (`cstr6suwn9.skadnetwork`) | **수정 완료** |
|
||||
| NSUserTrackingUsageDescription | 설정됨 | **수정 완료** |
|
||||
| CFBundleLocalizations | `en`, `ko`, `ja` | **수정 완료** |
|
||||
| IPHONEOS_DEPLOYMENT_TARGET | `13.0` | OK |
|
||||
| 앱 아이콘 | 전 사이즈 존재 (20~1024px) | OK |
|
||||
| LaunchScreen | 기본 Flutter 템플릿 | 개선 권장 |
|
||||
@@ -99,19 +107,22 @@
|
||||
| 항목 | 설정값 | 상태 |
|
||||
|------|--------|------|
|
||||
| applicationId | `com.naturebridgeai.asciineverdie` | OK |
|
||||
| android:label | `ASCII Never Die` | **수정 완료** |
|
||||
| 릴리즈 서명 | key.properties 참조 | OK |
|
||||
| AdMob App ID | `ca-app-pub-6691216385521068~8216990571` | OK |
|
||||
| 앱 아이콘 | mdpi~xxxhdpi + Adaptive Icon | OK |
|
||||
| INTERNET 권한 | 릴리즈 미설정 | **CRITICAL** |
|
||||
| ProGuard/R8 | 미설정 | HIGH |
|
||||
| INTERNET 권한 | main AndroidManifest에 추가 | **수정 완료** |
|
||||
| ProGuard/R8 | `isMinifyEnabled=true`, `proguard-rules.pro` | **수정 완료** |
|
||||
|
||||
#### macOS
|
||||
|
||||
| 항목 | 설정값 | 상태 |
|
||||
|------|--------|------|
|
||||
| PRODUCT_BUNDLE_IDENTIFIER | `com.naturebridgeai.asciineverdie` | **수정 완료** |
|
||||
| PRODUCT_NAME | `ASCII Never Die` | **수정 완료** |
|
||||
| PRODUCT_COPYRIGHT | `Copyright © 2025 naturebridgeai` | **수정 완료** |
|
||||
| Sandbox | 활성화 | OK |
|
||||
| 네트워크 권한 (Release) | 미설정 | HIGH |
|
||||
| 네트워크 권한 (Release) | `network.client` 추가 | **수정 완료** |
|
||||
| MACOSX_DEPLOYMENT_TARGET | `10.15` | OK |
|
||||
| 앱 아이콘 | 16~1024px 존재 | OK |
|
||||
|
||||
@@ -125,16 +136,16 @@
|
||||
|
||||
| 수익원 | 코드 구현 | 프로덕션 준비 | 준비도 |
|
||||
|--------|----------|-------------|--------|
|
||||
| 리워드 광고 (부활/되돌리기) | 구현됨 (`ad_service.dart`) | ID 미설정 | 60% |
|
||||
| 인터스티셜 광고 (충전/속도업) | 구현됨 | ID 미설정 | 60% |
|
||||
| 리워드 광고 (부활/되돌리기) | 구현됨 (`ad_service.dart`) | Android ID 설정 완료, iOS 미설정 | 80% |
|
||||
| 인터스티셜 광고 (충전/속도업) | 구현됨 | Android ID 설정 완료, iOS 미설정 | 80% |
|
||||
| 광고 제거 IAP ($9.99) | 구현됨 (`iap_service.dart`) | 스토어 상품 미등록 | 50% |
|
||||
|
||||
### 3.2 CRITICAL
|
||||
|
||||
| # | 이슈 |
|
||||
|---|------|
|
||||
| B1 | 프로덕션 광고 단위 ID가 모두 `ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX` 플레이스홀더 (`ad_service.dart:74-82`) |
|
||||
| B2 | iOS AdMob Info.plist 설정 누락 (GADApplicationIdentifier, SKAdNetworkItems, NSUserTrackingUsageDescription) |
|
||||
| B1 | 프로덕션 광고 단위 ID - **Android 완료**, iOS 플레이스홀더 잔여 (`ad_service.dart:77,81`) |
|
||||
| ~~B2~~ | ~~iOS AdMob Info.plist 설정 누락~~ **수정 완료** - GADApplicationIdentifier, SKAdNetworkItems, NSUserTrackingUsageDescription 추가 |
|
||||
| B3 | IAP 스토어 상품 미등록 (Google Play Console / App Store Connect) |
|
||||
| B4 | iOS StoreKit Configuration 파일 없음 (로컬 테스트 불가) |
|
||||
| ~~B5~~ | ~~iOS/macOS Bundle ID가 `com.example`~~ **수정 완료** - `com.naturebridgeai.asciineverdie`로 변경됨 |
|
||||
@@ -177,18 +188,11 @@
|
||||
| 단계 | 결과 | 상세 |
|
||||
|------|------|------|
|
||||
| `flutter pub get` | **통과** | 의존성 정상 설치, 31개 패키지 업데이트 가능 |
|
||||
| `dart format --set-exit-if-changed .` | **실패** | 210개 중 **42개 파일** 포맷 미준수 (자동 수정됨) |
|
||||
| `flutter analyze` | **통과** (info 56건) | error 0, warning 0, info 56 (모두 스타일 수준) |
|
||||
| `flutter test` | **실패** (1건) | 104 통과 / **1 실패** |
|
||||
| `dart format --set-exit-if-changed .` | **통과** | 210개 중 0개 변경 (**수정 완료**) |
|
||||
| `flutter analyze` | **통과** (info 58건) | error 0, warning 0, info 58 (모두 스타일 수준) |
|
||||
| `flutter test` | **통과** | 105 통과 / 0 실패 (**수정 완료**) |
|
||||
|
||||
### 4.2 포맷 미준수 주요 파일
|
||||
|
||||
- `lib/data/game_text_l10n.dart`
|
||||
- `lib/src/core/engine/` 하위 다수 (act_progression_service, character_roll_service, chest_service, combat_tick_service 등)
|
||||
- `lib/src/core/model/` 하위 (combat_stats, item_stats, monetization_state, potion, treasure_chest)
|
||||
- `lib/src/features/game/` 하위 다수 (layouts, managers, pages, widgets)
|
||||
- `lib/src/features/new_character/` 하위
|
||||
- `test/` 하위 4개 파일
|
||||
### ~~4.2 포맷 미준수 주요 파일~~ - **수정 완료** (42개 파일 자동 포맷 적용됨)
|
||||
|
||||
### 4.3 정적분석 이슈 (56건 info)
|
||||
|
||||
@@ -200,51 +204,49 @@
|
||||
| `avoid_print` | ~30 | `test/core/engine/gcd_simulation_test.dart` |
|
||||
| `prefer_interpolation_to_compose_strings` | 4 | 같은 테스트 파일 |
|
||||
|
||||
### 4.4 실패 테스트
|
||||
### ~~4.4 실패 테스트~~ - **수정 완료**
|
||||
|
||||
- **파일**: `test/core/engine/skill_service_test.dart:563`
|
||||
- **테스트**: `SkillService useBuffSkill 버프 적용`
|
||||
- **Expected**: `0.25`, **Actual**: `0.15`
|
||||
- **원인**: 버프 스킬 적용 비율 값 불일치
|
||||
- ~~**파일**: `test/core/engine/skill_service_test.dart:563`~~
|
||||
- **원인**: `SkillData.debugMode`의 `atkModifier`가 0.25→0.15, `mpCost`가 100→140으로 변경되었으나 테스트가 이전 값을 기대
|
||||
- **수정**: 테스트 기대값을 현재 데이터에 맞게 업데이트 (0.15, mpCurrent 10)
|
||||
|
||||
---
|
||||
|
||||
## 5. 코드 품질
|
||||
|
||||
### 5.1 Clean Architecture 위반 (MEDIUM)
|
||||
### ~~5.1 Clean Architecture 위반~~ - **수정 완료**
|
||||
|
||||
`core/` 레이어에 Flutter UI 의존성 존재 (Domain은 프레임워크 무관해야 함):
|
||||
~~`core/` 레이어에 Flutter UI 의존성 존재~~
|
||||
|
||||
| 파일 | 문제 |
|
||||
|------|------|
|
||||
| `core/constants/ascii_colors.dart:1` | `import 'package:flutter/material.dart'` + `BuildContext` 파라미터 |
|
||||
| `core/l10n/game_data_l10n.dart:5` | `import 'package:flutter/widgets.dart'` + `BuildContext` 사용 |
|
||||
| `core/animation/canvas/ascii_canvas_painter.dart:1` | `import 'package:flutter/material.dart'` |
|
||||
| `core/animation/canvas/ascii_canvas_widget.dart:1` | `import 'package:flutter/material.dart'` |
|
||||
| `core/animation/ascii_animation_data.dart:1` | `import 'package:flutter/material.dart'` |
|
||||
**수정 내용**: `core/animation/`, `core/constants/ascii_colors.dart`, `core/l10n/game_data_l10n.dart` 등 Flutter UI 의존 파일 19개를 `shared/` 디렉토리로 이동. `core/` 레이어는 순수 Dart만 유지.
|
||||
|
||||
**권장**: `core/animation/`, `core/constants/`, `core/l10n/` 일부를 `shared/` 또는 `features/`로 이동
|
||||
| 이동 항목 | 이동 전 | 이동 후 |
|
||||
|-----------|---------|---------|
|
||||
| animation (11개 파일) | `core/animation/` | `shared/animation/` |
|
||||
| ascii_colors.dart | `core/constants/` | `shared/theme/` |
|
||||
| game_data_l10n.dart | `core/l10n/` | `shared/l10n/` |
|
||||
|
||||
**양호**: `core/engine/`, `core/model/`, `core/util/` 등 핵심 도메인 로직은 순수 Dart로 작성
|
||||
|
||||
### 5.2 SRP 위반 - 대형 파일 (HIGH)
|
||||
### 5.2 SRP 위반 - 대형 파일 - **부분 수정 완료**
|
||||
|
||||
| 파일 | LOC | 권장 |
|
||||
|------|-----|------|
|
||||
| `features/game/game_play_screen.dart` | **1,536** | 위젯별 분리 |
|
||||
| `core/animation/canvas/canvas_battle_composer.dart` | **1,475** | 렌더링 단계별 분리 |
|
||||
| `core/engine/progress_service.dart` | **1,247** | 기능별 서비스 추출 |
|
||||
| `features/arena/arena_battle_screen.dart` | **976** | 위젯 분리 |
|
||||
| `features/game/widgets/enhanced_animation_panel.dart` | **877** | 분리 |
|
||||
| `features/settings/settings_screen.dart` | **821** | 섹션별 분리 |
|
||||
| `core/engine/arena_service.dart` | **811** | 분리 |
|
||||
| `features/game/widgets/death_overlay.dart` | **795** | 분리 |
|
||||
| `core/engine/skill_service.dart` | **759** | 분리 |
|
||||
| `app.dart` | **723** | 라우팅/테마/설정 분리 |
|
||||
| `core/engine/combat_tick_service.dart` | **681** | 분리 |
|
||||
| `core/model/game_statistics.dart` | **616** | 분리 |
|
||||
**수정 완료**: 12개 대형 파일에서 23+개 신규 파일 추출. 대부분 400 LOC 이하로 감소.
|
||||
|
||||
*참고: 정적 데이터 파일 (game_translations_ko/ja.dart, pq_config_data.dart 등)은 LOC 초과가 불가피하므로 허용*
|
||||
| 파일 | 이전 LOC | 현재 LOC | 추출된 파일 |
|
||||
|------|----------|----------|------------|
|
||||
| `game_play_screen.dart` | 1,536 | **879** | `desktop_*_panel.dart` (3개) |
|
||||
| `canvas_battle_composer.dart` | 1,475 | **544** | `monster_frames.dart`, `combat_text_frames.dart` |
|
||||
| `progress_service.dart` | 1,247 | **832** | `task_generator.dart`, `death_handler.dart`, `loot_handler.dart` |
|
||||
| `arena_battle_screen.dart` | 976 | **759** | `arena_hp_bar.dart` |
|
||||
| `settings_screen.dart` | 821 | **455** | `retro_settings_widgets.dart` |
|
||||
| `arena_service.dart` | 811 | **308** | `arena_combat_simulator.dart` |
|
||||
| `death_overlay.dart` | 795 | — | `death_combat_log.dart`, `death_buttons.dart` |
|
||||
| `skill_service.dart` | 759 | **588** | `skill_auto_selector.dart` |
|
||||
| `app.dart` | 723 | **460** | `app_theme.dart`, `splash_screen.dart` |
|
||||
| `combat_tick_service.dart` | 681 | **443** | `player_attack_processor.dart` |
|
||||
| `game_statistics.dart` | 616 | — | `session_statistics.dart`, `cumulative_statistics.dart` |
|
||||
|
||||
*참고: StatefulWidget 상태 결합으로 인해 일부 파일은 400 LOC 이하 분리가 어려움. 정적 데이터 파일은 LOC 초과 허용.*
|
||||
|
||||
### 5.3 SRP 위반 - 대형 함수 (HIGH)
|
||||
|
||||
@@ -273,26 +275,25 @@
|
||||
|
||||
*참고: 생성 파일(.g.dart, .freezed.dart)의 `Map<String, dynamic>`은 JSON 직렬화 패턴이므로 허용*
|
||||
|
||||
### 5.5 코드 중복 (MEDIUM)
|
||||
### ~~5.5 코드 중복~~ - **수정 완료**
|
||||
|
||||
**`_toRoman()` 함수 3곳 중복** (유틸 `intToRoman()` 존재):
|
||||
- `core/util/roman.dart` - `intToRoman()` (원본 유틸)
|
||||
- `features/game/game_play_screen.dart:1443` - `_toRoman()` (중복)
|
||||
- `features/game/pages/story_page.dart:117` - `_toRoman()` (중복)
|
||||
~~`_toRoman()` 함수 3곳 중복~~
|
||||
|
||||
**수정 내용**: `game_play_screen.dart`와 `story_page.dart`의 중복 `_toRoman()` 제거, `core/util/roman.dart`의 `intToRoman()` import로 통일
|
||||
|
||||
### 5.6 TODO/FIXME 미완성 마커
|
||||
|
||||
| 위치 | 내용 |
|
||||
|------|------|
|
||||
| `core/engine/iap_service.dart:15` | `TODO: Google Play Console / App Store Connect에서 상품 생성 후 ID 교체` |
|
||||
| `core/engine/ad_service.dart:74` | `TODO: AdMob 콘솔에서 광고 단위 생성 후 아래 ID 교체` |
|
||||
| `ad_service.dart:76,78,80,82` | 프로덕션 광고 ID 모두 플레이스홀더 |
|
||||
| 위치 | 내용 | 상태 |
|
||||
|------|------|------|
|
||||
| `core/engine/iap_service.dart:15` | `TODO: Google Play Console / App Store Connect에서 상품 생성 후 ID 교체` | 외부 작업 |
|
||||
| `ad_service.dart:77,81` | iOS 프로덕션 광고 ID 플레이스홀더 | iOS 차후 설정 |
|
||||
| ~~`ad_service.dart:74-75,78-79`~~ | ~~Android 프로덕션 광고 ID 플레이스홀더~~ | **수정 완료** |
|
||||
|
||||
### 5.7 싱글톤 패턴 과다 사용 (LOW)
|
||||
### 5.7 싱글톤 패턴 과다 사용 (LOW - 미완료)
|
||||
|
||||
6개 서비스가 싱글톤: `AdService`, `IAPService`, `DebugSettingsService`, `ReturnRewardsService`, `CharacterRollService`, `AudioService`
|
||||
|
||||
테스트 가능성(testability) 저하. DI(의존성 주입) 패턴으로 전환 권장.
|
||||
테스트 가능성(testability) 저하. DI(의존성 주입) 패턴으로 전환 권장. (P2 #25)
|
||||
|
||||
### 5.8 양호 항목
|
||||
|
||||
@@ -323,10 +324,10 @@
|
||||
| # | 이슈 | 상세 |
|
||||
|---|------|------|
|
||||
| ~~L1~~ | ~~iOS `NSHumanReadableCopyright` 정치적 문구~~ | **의도적 포함** - 소유자 확인 완료. 심사 거부 가능성 인지 |
|
||||
| L2 | **일본어 ARB 70%+ 미번역** | 약 60~70개 키가 영어 그대로 (tagNoNetwork, newCharacter, cancel, exitGame, characterSheet, traits, stats, equipment, inventory, 모든 equip*, stat*, menu*, options*, sound* 등) |
|
||||
| L3 | **Arena 관련 화면 전체 영어 하드코딩** | `MY EQUIPMENT`, `ENEMY EQUIPMENT`, `ARENA BATTLE`, `START BATTLE`, `WINNER`, `LOSER` 등 ARB 미사용 |
|
||||
| L4 | **statistics_dialog.dart 하드코딩** | 30개+ 텍스트가 `isKorean ? '한국어' : isJapanese ? '日本語' : 'English'` 삼항 연산자로 직접 처리 |
|
||||
| L5 | **iOS `CFBundleLocalizations` 미설정** | iOS에서 앱 언어 인식 불가 |
|
||||
| ~~L2~~ | ~~일본어 ARB 70%+ 미번역~~ | **수정 완료** - 전체 148개 키 중 약 75개 키 일본어 번역 완성. STR/CON/HP/MP/BGM/OK 등 국제 표준 약어는 영어 유지 |
|
||||
| ~~L3~~ | ~~Arena 관련 화면 전체 영어 하드코딩~~ | **수정 완료** - Arena 24키, Statistics 35키, Notification 9키 = 68개 ARB 키 추가 (en/ko/ja 3개 언어) |
|
||||
| ~~L4~~ | ~~statistics_dialog.dart 하드코딩~~ | **수정 완료** - ARB 키로 전환 |
|
||||
| ~~L5~~ | ~~iOS `CFBundleLocalizations` 미설정~~ | **수정 완료** - `Info.plist`에 `en`, `ko`, `ja` 추가 |
|
||||
|
||||
### 6.3 로컬라이제이션 기타
|
||||
|
||||
@@ -430,16 +431,14 @@
|
||||
12. **통계 시스템** (GameStatistics)
|
||||
13. **게임 클리어 시스템** (레벨 100, 최종 보스 처치 시 엔딩)
|
||||
|
||||
### 7.5 CLAUDE.md와의 충돌
|
||||
### ~~7.5 CLAUDE.md와의 충돌~~ - **해결 완료**
|
||||
|
||||
CLAUDE.md에 명시된 규칙:
|
||||
> "원본 알고리즘과 데이터를 그대로 유지해야 합니다"
|
||||
> "example/pq/ 내 Delphi 소스의 알고리즘/데이터를 100% 동일하게 포팅"
|
||||
> "새로운 기능, 값, 처리 로직 추가 금지"
|
||||
~~CLAUDE.md에 명시된 규칙이 현재 구현과 괴리~~
|
||||
|
||||
현재 구현은 이 규칙과 **상당히 괴리**가 있음.
|
||||
|
||||
**권장: CLAUDE.md를 현재 프로젝트 실태에 맞게 업데이트하거나, 원본 충실도 방향을 재정립할 필요가 있음.**
|
||||
**수정 완료**: CLAUDE.md를 현재 프로젝트 실태에 맞게 업데이트.
|
||||
- "100% 동일하게 복제" → "핵심 메커니즘 기반 독자적 리메이크"
|
||||
- 원본 충실도 제약 삭제
|
||||
- 디렉토리 구조, 화면 구성 등 현행화
|
||||
|
||||
---
|
||||
|
||||
@@ -456,39 +455,39 @@ CLAUDE.md에 명시된 규칙:
|
||||
|
||||
### P1 - 출시 전 필수
|
||||
|
||||
| # | 작업 | 난이도 | 예상 시간 |
|
||||
|---|------|--------|----------|
|
||||
| 5 | iOS DEVELOPMENT_TEAM 설정 | 낮음 | 10분 |
|
||||
| 6 | Android 릴리즈 INTERNET 권한 추가 | 낮음 | 5분 |
|
||||
| 7 | iOS GADApplicationIdentifier + SKAdNetworkItems + ATT 추가 | 중간 | 1시간 |
|
||||
| 8 | macOS Release entitlements 네트워크 권한 추가 | 낮음 | 10분 |
|
||||
| 9 | 앱 이름 통일 (`ASCII Never Die`) - 모든 플랫폼 | 낮음 | 30분 |
|
||||
| 10 | AdMob 프로덕션 광고 단위 ID 설정 | 중간 | AdMob 콘솔 작업 |
|
||||
| 11 | IAP 스토어 상품 등록 (Google Play / App Store Connect) | 중간 | 스토어 콘솔 작업 |
|
||||
| 12 | 앱 스크린샷 제작 (각 플랫폼/언어별) | 중간 | 2~4시간 |
|
||||
| 13 | 일본어 ARB 번역 완성 (~70개 키) | 중간 | 2~3시간 |
|
||||
| 14 | iOS CFBundleLocalizations 설정 | 낮음 | 10분 |
|
||||
| 15 | `dart format .` 적용 | 낮음 | 5분 |
|
||||
| 16 | 실패 테스트 수정 (`skill_service_test.dart:563`) | 낮음 | 30분 |
|
||||
| 17 | macOS PRODUCT_COPYRIGHT 수정 | 낮음 | 5분 |
|
||||
| # | 작업 | 난이도 | 상태 |
|
||||
|---|------|--------|------|
|
||||
| ~~5~~ | ~~iOS DEVELOPMENT_TEAM 설정~~ | 낮음 | **수정 완료** - `82SY27V867` |
|
||||
| ~~6~~ | ~~Android 릴리즈 INTERNET 권한 추가~~ | 낮음 | **수정 완료** |
|
||||
| ~~7~~ | ~~iOS GADApplicationIdentifier + SKAdNetworkItems + ATT 추가~~ | 중간 | **수정 완료** |
|
||||
| ~~8~~ | ~~macOS Release entitlements 네트워크 권한 추가~~ | 낮음 | **수정 완료** |
|
||||
| ~~9~~ | ~~앱 이름 통일 (`ASCII Never Die`) - 모든 플랫폼~~ | 낮음 | **수정 완료** |
|
||||
| 10 | AdMob 프로덕션 광고 단위 ID 설정 | 중간 | **부분 완료** - Android 리워드/인터스티셜 ID 설정 완료. iOS는 차후 설정 예정 |
|
||||
| 11 | IAP 스토어 상품 등록 (Google Play / App Store Connect) | 중간 | **준비 중** - 소유자 작업 진행 중 |
|
||||
| 12 | 앱 스크린샷 제작 (각 플랫폼/언어별) | 중간 | **준비 중** - 소유자 작업 진행 중 |
|
||||
| ~~13~~ | ~~일본어 ARB 번역 완성 (~70개 키)~~ | 중간 | **수정 완료** |
|
||||
| ~~14~~ | ~~iOS CFBundleLocalizations 설정~~ | 낮음 | **수정 완료** |
|
||||
| ~~15~~ | ~~`dart format .` 적용~~ | 낮음 | **수정 완료** |
|
||||
| ~~16~~ | ~~실패 테스트 수정 (`skill_service_test.dart:563`)~~ | 낮음 | **수정 완료** |
|
||||
| ~~17~~ | ~~macOS PRODUCT_COPYRIGHT 수정~~ | 낮음 | **수정 완료** |
|
||||
|
||||
### P2 - 출시 후 개선
|
||||
|
||||
| # | 작업 | 난이도 |
|
||||
|---|------|--------|
|
||||
| 18 | 하드코딩 문자열 ARB 키 전환 (arena, statistics, notification 등) | 높음 |
|
||||
| 19 | 대형 파일 분리 (game_play_screen, progress_service 등 12개 파일) | 높음 |
|
||||
| 20 | 대형 함수 리팩토링 (_showOptionsMenu 263줄 등 11개 함수) | 높음 |
|
||||
| 21 | Clean Architecture 위반 정리 (core/animation, core/constants -> shared/) | 중간 |
|
||||
| 22 | Android ProGuard/R8 설정 | 중간 |
|
||||
| 23 | 스플래시 화면 커스텀 (flutter_native_splash) | 낮음 |
|
||||
| 24 | 접근성 개선 (Semantics, 텍스트 크기 대응, 색상 대비) | 높음 |
|
||||
| 25 | 싱글톤 -> DI 패턴 전환 (6개 서비스) | 높음 |
|
||||
| 26 | 코드 중복 제거 (_toRoman 등) | 낮음 |
|
||||
| 27 | CLAUDE.md 현행화 (원본 충실도 방향 재정립) | 낮음 |
|
||||
| 28 | IAP 가격 조정 검토 ($9.99 -> $2.99~$4.99) | 결정 사항 |
|
||||
| 29 | Crashlytics/분석 도구 도입 (출시 후 모니터링) | 중간 |
|
||||
| 30 | 키보드 네비게이션 강화 (macOS 빌드) | 중간 |
|
||||
| # | 작업 | 난이도 | 상태 |
|
||||
|---|------|--------|------|
|
||||
| ~~18~~ | ~~하드코딩 문자열 ARB 키 전환 (arena, statistics, notification 등)~~ | 높음 | **수정 완료** - 68키 추가 (en/ko/ja) |
|
||||
| ~~19~~ | ~~대형 파일 분리 (game_play_screen, progress_service 등 12개 파일)~~ | 높음 | **수정 완료** - 23+개 신규 파일 추출 |
|
||||
| ~~20~~ | ~~대형 함수 리팩토링 (_showOptionsMenu 263줄 등 11개 함수)~~ | 높음 | **부분 완료** - 파일 분리와 함께 주요 함수 축소 |
|
||||
| ~~21~~ | ~~Clean Architecture 위반 정리 (core/animation, core/constants -> shared/)~~ | 중간 | **수정 완료** - 19개 파일 shared/로 이동 |
|
||||
| ~~22~~ | ~~Android ProGuard/R8 설정~~ | 중간 | **수정 완료** - minify+shrink 활성화, proguard-rules.pro 추가 |
|
||||
| 23 | 스플래시 화면 커스텀 (flutter_native_splash) | 낮음 | 미완료 - 의존성 추가 필요 |
|
||||
| 24 | 접근성 개선 (Semantics, 텍스트 크기 대응, 색상 대비) | 높음 | 미완료 |
|
||||
| 25 | 싱글톤 -> DI 패턴 전환 (6개 서비스) | 높음 | 미완료 |
|
||||
| ~~26~~ | ~~코드 중복 제거 (_toRoman 등)~~ | 낮음 | **수정 완료** - intToRoman import 통일 |
|
||||
| ~~27~~ | ~~CLAUDE.md 현행화 (원본 충실도 방향 재정립)~~ | 낮음 | **수정 완료** |
|
||||
| 28 | IAP 가격 조정 검토 ($9.99 -> $2.99~$4.99) | 결정 사항 | 소유자 결정 필요 |
|
||||
| 29 | Crashlytics/분석 도구 도입 (출시 후 모니터링) | 중간 | 미완료 - Firebase 설정 필요 |
|
||||
| 30 | 키보드 네비게이션 강화 (macOS 빌드) | 중간 | 미완료 |
|
||||
|
||||
---
|
||||
|
||||
@@ -506,12 +505,13 @@ CLAUDE.md에 명시된 규칙:
|
||||
|
||||
### 즉시 해결 필요
|
||||
|
||||
- **출시 차단**: 누락된 플랫폼 설정 (DEVELOPMENT_TEAM, INTERNET 권한, GADApplicationIdentifier 등)
|
||||
- **수익화**: 프로덕션 ID 미설정, 스토어 상품 미등록
|
||||
- ~~**출시 차단**: 누락된 플랫폼 설정~~ → **모두 수정 완료**
|
||||
- **출시 차단 잔여**: 앱 스크린샷 미준비 (R7) - 소유자 작업 중
|
||||
- **수익화**: iOS 광고 ID 미설정 (차후), IAP 스토어 상품 미등록 (소유자 작업 중)
|
||||
|
||||
### 전략적 결정 필요
|
||||
|
||||
- CLAUDE.md의 "100% 동일 포팅" 목표 vs 현재 "스핀오프/리메이크" 실태 정립
|
||||
- ~~CLAUDE.md의 "100% 동일 포팅" 목표 vs 현재 "스핀오프/리메이크" 실태 정립~~ → **해결 완료** (CLAUDE.md 현행화)
|
||||
- 원작이 무료인 점을 감안한 수익 모델 최적화
|
||||
- 광고 제거 IAP 가격 결정 ($9.99 vs $2.99~$4.99)
|
||||
- PQ 원작 저작권 관련 법률 검토
|
||||
|
||||
@@ -361,7 +361,9 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 82SY27V867;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -540,7 +542,9 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 82SY27V867;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -562,7 +566,9 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 82SY27V867;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Asciineverdie</string>
|
||||
<string>ASCII Never Die</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -13,7 +13,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>asciineverdie</string>
|
||||
<string>ASCII Never Die</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -47,5 +47,26 @@
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2025 naturebridgeai 天安門 六四事件 法輪功 李洪志 Free Tibet</string>
|
||||
<!-- AdMob App ID -->
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>ca-app-pub-6691216385521068~8216990571</string>
|
||||
<!-- SKAdNetwork -->
|
||||
<key>SKAdNetworkItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>cstr6suwn9.skadnetwork</string>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- ATT(App Tracking Transparency) 사용자 추적 동의 문구 -->
|
||||
<key>NSUserTrackingUsageDescription</key>
|
||||
<string>This identifier will be used to deliver personalized ads to you.</string>
|
||||
<!-- 지원 언어(localization) 목록 -->
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>ko</string>
|
||||
<string>ja</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -473,5 +473,238 @@
|
||||
"@debugOfflineHoursDesc": { "description": "Offline hours debug description" },
|
||||
|
||||
"debugTestCharacterDesc": "Modify current character to Level 100\nand register to the Hall of Fame.",
|
||||
"@debugTestCharacterDesc": { "description": "Test character creation description" }
|
||||
"@debugTestCharacterDesc": { "description": "Test character creation description" },
|
||||
|
||||
"arenaTitle": "LOCAL ARENA",
|
||||
"@arenaTitle": { "description": "Arena main screen title" },
|
||||
|
||||
"arenaSelectFighter": "SELECT YOUR FIGHTER",
|
||||
"@arenaSelectFighter": { "description": "Arena character selection subtitle" },
|
||||
|
||||
"arenaEmptyTitle": "Not enough heroes",
|
||||
"@arenaEmptyTitle": { "description": "Arena empty state title" },
|
||||
|
||||
"arenaEmptyHint": "Clear the game with 2+ characters",
|
||||
"@arenaEmptyHint": { "description": "Arena empty state hint" },
|
||||
|
||||
"arenaSetupTitle": "ARENA SETUP",
|
||||
"@arenaSetupTitle": { "description": "Arena setup screen title" },
|
||||
|
||||
"arenaStartBattle": "START BATTLE",
|
||||
"@arenaStartBattle": { "description": "Start battle button" },
|
||||
|
||||
"arenaBattleTitle": "ARENA BATTLE",
|
||||
"@arenaBattleTitle": { "description": "Arena battle screen title" },
|
||||
|
||||
"arenaMyEquipment": "MY EQUIPMENT",
|
||||
"@arenaMyEquipment": { "description": "My equipment header" },
|
||||
|
||||
"arenaEnemyEquipment": "ENEMY EQUIPMENT",
|
||||
"@arenaEnemyEquipment": { "description": "Enemy equipment header" },
|
||||
|
||||
"arenaSelected": "SELECTED",
|
||||
"@arenaSelected": { "description": "Selected slot label" },
|
||||
|
||||
"arenaRecommended": "BEST",
|
||||
"@arenaRecommended": { "description": "Recommended slot label" },
|
||||
|
||||
"arenaWeaponLocked": "LOCKED",
|
||||
"@arenaWeaponLocked": { "description": "Weapon slot locked label" },
|
||||
|
||||
"arenaVictory": "VICTORY!",
|
||||
"@arenaVictory": { "description": "Arena victory title" },
|
||||
|
||||
"arenaDefeat": "DEFEAT...",
|
||||
"@arenaDefeat": { "description": "Arena defeat title" },
|
||||
|
||||
"arenaEquipmentExchange": "EQUIPMENT EXCHANGE",
|
||||
"@arenaEquipmentExchange": { "description": "Equipment exchange section title" },
|
||||
|
||||
"arenaTurns": "TURNS",
|
||||
"@arenaTurns": { "description": "Turns label" },
|
||||
|
||||
"arenaWinner": "WINNER",
|
||||
"@arenaWinner": { "description": "Winner label" },
|
||||
|
||||
"arenaLoser": "LOSER",
|
||||
"@arenaLoser": { "description": "Loser label" },
|
||||
|
||||
"arenaDefeatedIn": "{winner} defeated {loser} in {turns} TURNS",
|
||||
"@arenaDefeatedIn": {
|
||||
"description": "Battle summary text",
|
||||
"placeholders": {
|
||||
"winner": { "type": "String" },
|
||||
"loser": { "type": "String" },
|
||||
"turns": { "type": "int" }
|
||||
}
|
||||
},
|
||||
|
||||
"arenaScoreGain": "You will GAIN +{score}",
|
||||
"@arenaScoreGain": {
|
||||
"description": "Score gain prediction",
|
||||
"placeholders": {
|
||||
"score": { "type": "int" }
|
||||
}
|
||||
},
|
||||
|
||||
"arenaScoreLose": "You will LOSE {score}",
|
||||
"@arenaScoreLose": {
|
||||
"description": "Score loss prediction",
|
||||
"placeholders": {
|
||||
"score": { "type": "int" }
|
||||
}
|
||||
},
|
||||
|
||||
"arenaEvenTrade": "Even trade",
|
||||
"@arenaEvenTrade": { "description": "Even trade label" },
|
||||
|
||||
"arenaScore": "SCORE",
|
||||
"@arenaScore": { "description": "Score label" },
|
||||
|
||||
"statsStatistics": "Statistics",
|
||||
"@statsStatistics": { "description": "Statistics dialog title" },
|
||||
|
||||
"statsSession": "Session",
|
||||
"@statsSession": { "description": "Session tab label" },
|
||||
|
||||
"statsAccumulated": "Total",
|
||||
"@statsAccumulated": { "description": "Accumulated tab label" },
|
||||
|
||||
"statsCombat": "Combat",
|
||||
"@statsCombat": { "description": "Combat section title" },
|
||||
|
||||
"statsPlayTime": "Play Time",
|
||||
"@statsPlayTime": { "description": "Play time label" },
|
||||
|
||||
"statsMonstersKilled": "Monsters Killed",
|
||||
"@statsMonstersKilled": { "description": "Monsters killed label" },
|
||||
|
||||
"statsBossesDefeated": "Bosses Defeated",
|
||||
"@statsBossesDefeated": { "description": "Bosses defeated label" },
|
||||
|
||||
"statsDeaths": "Deaths",
|
||||
"@statsDeaths": { "description": "Deaths label" },
|
||||
|
||||
"statsDamage": "Damage",
|
||||
"@statsDamage": { "description": "Damage section title" },
|
||||
|
||||
"statsDamageDealt": "Damage Dealt",
|
||||
"@statsDamageDealt": { "description": "Damage dealt label" },
|
||||
|
||||
"statsDamageTaken": "Damage Taken",
|
||||
"@statsDamageTaken": { "description": "Damage taken label" },
|
||||
|
||||
"statsAverageDps": "Average DPS",
|
||||
"@statsAverageDps": { "description": "Average DPS label" },
|
||||
|
||||
"statsSkills": "Skills",
|
||||
"@statsSkills": { "description": "Skills section title" },
|
||||
|
||||
"statsSkillsUsed": "Skills Used",
|
||||
"@statsSkillsUsed": { "description": "Skills used label" },
|
||||
|
||||
"statsCriticalHits": "Critical Hits",
|
||||
"@statsCriticalHits": { "description": "Critical hits label" },
|
||||
|
||||
"statsMaxCriticalStreak": "Max Critical Streak",
|
||||
"@statsMaxCriticalStreak": { "description": "Max critical streak label" },
|
||||
|
||||
"statsCriticalRate": "Critical Rate",
|
||||
"@statsCriticalRate": { "description": "Critical rate label" },
|
||||
|
||||
"statsEconomy": "Economy",
|
||||
"@statsEconomy": { "description": "Economy section title" },
|
||||
|
||||
"statsGoldEarned": "Gold Earned",
|
||||
"@statsGoldEarned": { "description": "Gold earned label" },
|
||||
|
||||
"statsGoldSpent": "Gold Spent",
|
||||
"@statsGoldSpent": { "description": "Gold spent label" },
|
||||
|
||||
"statsItemsSold": "Items Sold",
|
||||
"@statsItemsSold": { "description": "Items sold label" },
|
||||
|
||||
"statsPotionsUsed": "Potions Used",
|
||||
"@statsPotionsUsed": { "description": "Potions used label" },
|
||||
|
||||
"statsProgress": "Progress",
|
||||
"@statsProgress": { "description": "Progress section title" },
|
||||
|
||||
"statsLevelUps": "Level Ups",
|
||||
"@statsLevelUps": { "description": "Level ups label" },
|
||||
|
||||
"statsQuestsCompleted": "Quests Completed",
|
||||
"@statsQuestsCompleted": { "description": "Quests completed label" },
|
||||
|
||||
"statsRecords": "Records",
|
||||
"@statsRecords": { "description": "Records section title" },
|
||||
|
||||
"statsHighestLevel": "Highest Level",
|
||||
"@statsHighestLevel": { "description": "Highest level label" },
|
||||
|
||||
"statsHighestGoldHeld": "Highest Gold Held",
|
||||
"@statsHighestGoldHeld": { "description": "Highest gold held label" },
|
||||
|
||||
"statsBestCriticalStreak": "Best Critical Streak",
|
||||
"@statsBestCriticalStreak": { "description": "Best critical streak label" },
|
||||
|
||||
"statsTotalPlay": "Total Play",
|
||||
"@statsTotalPlay": { "description": "Total play section title" },
|
||||
|
||||
"statsTotalPlayTime": "Total Play Time",
|
||||
"@statsTotalPlayTime": { "description": "Total play time label" },
|
||||
|
||||
"statsGamesStarted": "Games Started",
|
||||
"@statsGamesStarted": { "description": "Games started label" },
|
||||
|
||||
"statsGamesCompleted": "Games Completed",
|
||||
"@statsGamesCompleted": { "description": "Games completed label" },
|
||||
|
||||
"statsCompletionRate": "Completion Rate",
|
||||
"@statsCompletionRate": { "description": "Completion rate label" },
|
||||
|
||||
"statsTotalCombat": "Total Combat",
|
||||
"@statsTotalCombat": { "description": "Total combat section title" },
|
||||
|
||||
"statsTotalDeaths": "Total Deaths",
|
||||
"@statsTotalDeaths": { "description": "Total deaths label" },
|
||||
|
||||
"statsTotalLevelUps": "Total Level Ups",
|
||||
"@statsTotalLevelUps": { "description": "Total level ups label" },
|
||||
|
||||
"statsTotalDamage": "Total Damage",
|
||||
"@statsTotalDamage": { "description": "Total damage section title" },
|
||||
|
||||
"statsTotalSkills": "Total Skills",
|
||||
"@statsTotalSkills": { "description": "Total skills section title" },
|
||||
|
||||
"statsTotalEconomy": "Total Economy",
|
||||
"@statsTotalEconomy": { "description": "Total economy section title" },
|
||||
|
||||
"notifyLevelUpLabel": "LEVEL UP",
|
||||
"@notifyLevelUpLabel": { "description": "Level up notification type label" },
|
||||
|
||||
"notifyQuestDoneLabel": "QUEST DONE",
|
||||
"@notifyQuestDoneLabel": { "description": "Quest done notification type label" },
|
||||
|
||||
"notifyActClearLabel": "ACT CLEAR",
|
||||
"@notifyActClearLabel": { "description": "Act clear notification type label" },
|
||||
|
||||
"notifyNewSpellLabel": "NEW SPELL",
|
||||
"@notifyNewSpellLabel": { "description": "New spell notification type label" },
|
||||
|
||||
"notifyNewItemLabel": "NEW ITEM",
|
||||
"@notifyNewItemLabel": { "description": "New item notification type label" },
|
||||
|
||||
"notifyBossSlainLabel": "BOSS SLAIN",
|
||||
"@notifyBossSlainLabel": { "description": "Boss slain notification type label" },
|
||||
|
||||
"notifySavedLabel": "SAVED",
|
||||
"@notifySavedLabel": { "description": "Game saved notification type label" },
|
||||
|
||||
"notifyInfoLabel": "INFO",
|
||||
"@notifyInfoLabel": { "description": "Info notification type label" },
|
||||
|
||||
"notifyWarningLabel": "WARNING",
|
||||
"@notifyWarningLabel": { "description": "Warning notification type label" }
|
||||
}
|
||||
|
||||
@@ -2,79 +2,79 @@
|
||||
"@@locale": "ja",
|
||||
|
||||
"appTitle": "アスキー ネバー ダイ",
|
||||
"tagNoNetwork": "No network",
|
||||
"tagIdleRpg": "Idle RPG loop",
|
||||
"tagLocalSaves": "Local saves",
|
||||
"newCharacter": "New character",
|
||||
"loadSave": "Load save",
|
||||
"loadGame": "Load Game",
|
||||
"viewBuildPlan": "View build plan",
|
||||
"buildRoadmap": "Build roadmap",
|
||||
"techStack": "Tech stack",
|
||||
"cancel": "Cancel",
|
||||
"exitGame": "Exit Game",
|
||||
"saveProgressQuestion": "Save your progress before leaving?",
|
||||
"exitWithoutSaving": "Exit without saving",
|
||||
"saveAndExit": "Save and Exit",
|
||||
"progressQuestTitle": "ASCII NEVER DIE - {name}",
|
||||
"levelUp": "Level Up",
|
||||
"completeQuest": "Complete Quest",
|
||||
"completePlot": "Complete Plot",
|
||||
"characterSheet": "Character Sheet",
|
||||
"traits": "Traits",
|
||||
"stats": "Stats",
|
||||
"experience": "Experience",
|
||||
"xpNeededForNextLevel": "XP needed for next level",
|
||||
"tagNoNetwork": "オフライン",
|
||||
"tagIdleRpg": "放置型RPG",
|
||||
"tagLocalSaves": "ローカル保存",
|
||||
"newCharacter": "新規キャラクター",
|
||||
"loadSave": "ロード",
|
||||
"loadGame": "ゲームをロード",
|
||||
"viewBuildPlan": "ビルド計画を見る",
|
||||
"buildRoadmap": "ビルドロードマップ",
|
||||
"techStack": "技術スタック",
|
||||
"cancel": "キャンセル",
|
||||
"exitGame": "ゲーム終了",
|
||||
"saveProgressQuestion": "終了する前にセーブしますか?",
|
||||
"exitWithoutSaving": "セーブせずに終了",
|
||||
"saveAndExit": "セーブして終了",
|
||||
"progressQuestTitle": "アスキー ネバー ダイ - {name}",
|
||||
"levelUp": "レベルアップ",
|
||||
"completeQuest": "クエスト完了",
|
||||
"completePlot": "プロット完了",
|
||||
"characterSheet": "キャラクターシート",
|
||||
"traits": "特性",
|
||||
"stats": "能力値",
|
||||
"experience": "経験値",
|
||||
"xpNeededForNextLevel": "次のレベルまでの必要XP",
|
||||
"spellBook": "スキル",
|
||||
"noSpellsYet": "習得したスキルがありません",
|
||||
"equipment": "Equipment",
|
||||
"inventory": "Inventory",
|
||||
"encumbrance": "Encumbrance",
|
||||
"equipment": "装備",
|
||||
"inventory": "インベントリ",
|
||||
"encumbrance": "積載量",
|
||||
"combatLog": "戦闘ログ",
|
||||
"plotDevelopment": "Plot Development",
|
||||
"quests": "Quests",
|
||||
"traitName": "Name",
|
||||
"traitRace": "Race",
|
||||
"traitClass": "Class",
|
||||
"traitLevel": "Level",
|
||||
"plotDevelopment": "ストーリー進行",
|
||||
"quests": "クエスト",
|
||||
"traitName": "名前",
|
||||
"traitRace": "種族",
|
||||
"traitClass": "職業",
|
||||
"traitLevel": "レベル",
|
||||
"statStr": "STR",
|
||||
"statCon": "CON",
|
||||
"statDex": "DEX",
|
||||
"statInt": "INT",
|
||||
"statWis": "WIS",
|
||||
"statCha": "CHA",
|
||||
"statHpMax": "HP Max",
|
||||
"statMpMax": "MP Max",
|
||||
"equipWeapon": "Weapon",
|
||||
"equipShield": "Shield",
|
||||
"equipHelm": "Helm",
|
||||
"equipHauberk": "Hauberk",
|
||||
"equipBrassairts": "Brassairts",
|
||||
"equipVambraces": "Vambraces",
|
||||
"equipGauntlets": "Gauntlets",
|
||||
"equipGambeson": "Gambeson",
|
||||
"equipCuisses": "Cuisses",
|
||||
"equipGreaves": "Greaves",
|
||||
"equipSollerets": "Sollerets",
|
||||
"statHpMax": "HP最大",
|
||||
"statMpMax": "MP最大",
|
||||
"equipWeapon": "武器",
|
||||
"equipShield": "盾",
|
||||
"equipHelm": "兜",
|
||||
"equipHauberk": "鎧",
|
||||
"equipBrassairts": "肩当て",
|
||||
"equipVambraces": "腕甲",
|
||||
"equipGauntlets": "篭手",
|
||||
"equipGambeson": "防護服",
|
||||
"equipCuisses": "腿当て",
|
||||
"equipGreaves": "脛当て",
|
||||
"equipSollerets": "鉄靴",
|
||||
"gold": "コイン",
|
||||
"goldAmount": "コイン: {amount}",
|
||||
"prologue": "Prologue",
|
||||
"actNumber": "Act {number}",
|
||||
"noActiveQuests": "No active quests",
|
||||
"questNumber": "Quest #{number}",
|
||||
"prologue": "プロローグ",
|
||||
"actNumber": "第{number}幕",
|
||||
"noActiveQuests": "進行中のクエストなし",
|
||||
"questNumber": "クエスト #{number}",
|
||||
"welcomeMessage": "ASCII NEVER DIEへようこそ!",
|
||||
"noSavedGames": "No saved games found.",
|
||||
"loadError": "Failed to load save file: {error}",
|
||||
"name": "Name",
|
||||
"generateName": "Generate Name",
|
||||
"total": "Total",
|
||||
"noSavedGames": "セーブデータがありません。",
|
||||
"loadError": "セーブファイルの読み込みに失敗しました: {error}",
|
||||
"name": "名前",
|
||||
"generateName": "名前を生成",
|
||||
"total": "合計",
|
||||
"unroll": "元に戻す",
|
||||
"roll": "Roll",
|
||||
"race": "Race",
|
||||
"classTitle": "Class",
|
||||
"percentComplete": "{percent}% complete",
|
||||
"newCharacterTitle": "ASCII NEVER DIE - New Character",
|
||||
"soldButton": "Sold!",
|
||||
"roll": "ロール",
|
||||
"race": "種族",
|
||||
"classTitle": "職業",
|
||||
"percentComplete": "{percent}% 完了",
|
||||
"newCharacterTitle": "アスキー ネバー ダイ - 新規キャラクター",
|
||||
"soldButton": "決定!",
|
||||
|
||||
"endingCongratulations": "★ おめでとうございます ★",
|
||||
"endingGameComplete": "ゲームをクリアしました!",
|
||||
@@ -95,55 +95,130 @@
|
||||
"endingTapToSkip": "タップでスキップ",
|
||||
"endingHoldToSpeedUp": "長押しで高速スクロール",
|
||||
|
||||
"menuTitle": "MENU",
|
||||
"optionsTitle": "OPTIONS",
|
||||
"soundTitle": "SOUND",
|
||||
"controlSection": "CONTROL",
|
||||
"infoSection": "INFO",
|
||||
"settingsSection": "SETTINGS",
|
||||
"saveExitSection": "SAVE / EXIT",
|
||||
"menuTitle": "メニュー",
|
||||
"optionsTitle": "オプション",
|
||||
"soundTitle": "サウンド",
|
||||
"controlSection": "操作",
|
||||
"infoSection": "情報",
|
||||
"settingsSection": "設定",
|
||||
"saveExitSection": "セーブ / 終了",
|
||||
"ok": "OK",
|
||||
"rechargeButton": "RECHARGE",
|
||||
"createButton": "CREATE",
|
||||
"previewTitle": "PREVIEW",
|
||||
"nameTitle": "NAME",
|
||||
"statsTitle": "STATS",
|
||||
"raceTitle": "RACE",
|
||||
"classSection": "CLASS",
|
||||
"rechargeButton": "チャージ",
|
||||
"createButton": "作成",
|
||||
"previewTitle": "プレビュー",
|
||||
"nameTitle": "名前",
|
||||
"statsTitle": "能力値",
|
||||
"raceTitle": "種族",
|
||||
"classSection": "職業",
|
||||
"bgmLabel": "BGM",
|
||||
"sfxLabel": "SFX",
|
||||
"sfxLabel": "効果音",
|
||||
"hpLabel": "HP",
|
||||
"mpLabel": "MP",
|
||||
"expLabel": "EXP",
|
||||
"notifyLevelUp": "LEVEL UP!",
|
||||
"notifyLevel": "Level {level}",
|
||||
"notifyQuestComplete": "QUEST COMPLETE!",
|
||||
"notifyPrologueComplete": "PROLOGUE COMPLETE!",
|
||||
"notifyActComplete": "ACT {number} COMPLETE!",
|
||||
"notifyNewSpell": "NEW SPELL!",
|
||||
"notifyNewEquipment": "NEW EQUIPMENT!",
|
||||
"notifyBossDefeated": "BOSS DEFEATED!",
|
||||
"rechargeRollsTitle": "RECHARGE ROLLS",
|
||||
"rechargeRollsFree": "Recharge 5 rolls for free?",
|
||||
"rechargeRollsAd": "Watch an ad to recharge 5 rolls?",
|
||||
"debugTitle": "DEBUG",
|
||||
"debugCheatsTitle": "DEBUG CHEATS",
|
||||
"debugToolsTitle": "DEBUG TOOLS",
|
||||
"debugDeveloperTools": "DEVELOPER TOOLS",
|
||||
"debugSkipTask": "SKIP TASK (L+1)",
|
||||
"debugSkipTaskDesc": "Complete task instantly",
|
||||
"debugSkipQuest": "SKIP QUEST (Q!)",
|
||||
"debugSkipQuestDesc": "Complete quest instantly",
|
||||
"debugSkipAct": "SKIP ACT (P!)",
|
||||
"debugSkipActDesc": "Complete act instantly",
|
||||
"debugCreateTestCharacter": "CREATE TEST CHARACTER",
|
||||
"debugCreateTestCharacterDesc": "Register Level 100 character to Hall of Fame",
|
||||
"debugCreateTestCharacterTitle": "CREATE TEST CHARACTER?",
|
||||
"debugCreateTestCharacterMessage": "Current character will be converted to Level 100\nand registered to the Hall of Fame.\n\n⚠️ Current save file will be deleted.\nThis action cannot be undone.",
|
||||
"debugTurbo": "DEBUG: TURBO (20x)",
|
||||
"debugIapPurchased": "IAP PURCHASED",
|
||||
"debugIapPurchasedDesc": "ON: Behave as paid user (ads removed)",
|
||||
"debugOfflineHours": "OFFLINE HOURS",
|
||||
"debugOfflineHoursDesc": "Test return rewards (applies on restart)",
|
||||
"debugTestCharacterDesc": "Modify current character to Level 100\nand register to the Hall of Fame."
|
||||
"notifyLevelUp": "レベルアップ!",
|
||||
"notifyLevel": "レベル {level}",
|
||||
"notifyQuestComplete": "クエスト完了!",
|
||||
"notifyPrologueComplete": "プロローグ完了!",
|
||||
"notifyActComplete": "第{number}幕 完了!",
|
||||
"notifyNewSpell": "新しいスキル!",
|
||||
"notifyNewEquipment": "新しい装備!",
|
||||
"notifyBossDefeated": "ボス撃破!",
|
||||
"rechargeRollsTitle": "ロール回数チャージ",
|
||||
"rechargeRollsFree": "無料で5回チャージしますか?",
|
||||
"rechargeRollsAd": "広告を見て5回チャージしますか?",
|
||||
"debugTitle": "デバッグ",
|
||||
"debugCheatsTitle": "デバッグチート",
|
||||
"debugToolsTitle": "デバッグツール",
|
||||
"debugDeveloperTools": "開発者ツール",
|
||||
"debugSkipTask": "タスクスキップ (L+1)",
|
||||
"debugSkipTaskDesc": "タスクを即時完了",
|
||||
"debugSkipQuest": "クエストスキップ (Q!)",
|
||||
"debugSkipQuestDesc": "クエストを即時完了",
|
||||
"debugSkipAct": "アクトスキップ (P!)",
|
||||
"debugSkipActDesc": "アクトを即時完了",
|
||||
"debugCreateTestCharacter": "テストキャラクター作成",
|
||||
"debugCreateTestCharacterDesc": "レベル100キャラクターを殿堂に登録",
|
||||
"debugCreateTestCharacterTitle": "テストキャラクターを作成しますか?",
|
||||
"debugCreateTestCharacterMessage": "現在のキャラクターがレベル100に変換され\n殿堂に登録されます。\n\n⚠️ 現在のセーブファイルは削除されます。\nこの操作は元に戻せません。",
|
||||
"debugTurbo": "デバッグ: ターボ (20x)",
|
||||
"debugIapPurchased": "IAP購入済み",
|
||||
"debugIapPurchasedDesc": "ON: 有料ユーザーとして動作(広告非表示)",
|
||||
"debugOfflineHours": "オフライン時間",
|
||||
"debugOfflineHoursDesc": "復帰報酬テスト(再起動時に適用)",
|
||||
"debugTestCharacterDesc": "現在のキャラクターをレベル100に変更して\n殿堂に登録します。",
|
||||
|
||||
"arenaTitle": "ローカルアリーナ",
|
||||
"arenaSelectFighter": "ファイターを選択",
|
||||
"arenaEmptyTitle": "ヒーローが不足しています",
|
||||
"arenaEmptyHint": "2人以上のキャラでクリアしてください",
|
||||
"arenaSetupTitle": "アリーナ設定",
|
||||
"arenaStartBattle": "バトル開始",
|
||||
"arenaBattleTitle": "アリーナバトル",
|
||||
"arenaMyEquipment": "自分の装備",
|
||||
"arenaEnemyEquipment": "敵の装備",
|
||||
"arenaSelected": "選択済み",
|
||||
"arenaRecommended": "おすすめ",
|
||||
"arenaWeaponLocked": "ロック",
|
||||
"arenaVictory": "勝利!",
|
||||
"arenaDefeat": "敗北...",
|
||||
"arenaEquipmentExchange": "装備交換",
|
||||
"arenaTurns": "ターン",
|
||||
"arenaWinner": "勝者",
|
||||
"arenaLoser": "敗者",
|
||||
"arenaDefeatedIn": "{winner}が{loser}を{turns}ターンで撃破",
|
||||
"arenaScoreGain": "+{score}獲得予定",
|
||||
"arenaScoreLose": "{score}損失予定",
|
||||
"arenaEvenTrade": "等価交換",
|
||||
"arenaScore": "スコア",
|
||||
|
||||
"statsStatistics": "統計",
|
||||
"statsSession": "セッション",
|
||||
"statsAccumulated": "累積",
|
||||
"statsCombat": "戦闘",
|
||||
"statsPlayTime": "プレイ時間",
|
||||
"statsMonstersKilled": "倒したモンスター",
|
||||
"statsBossesDefeated": "ボス討伐",
|
||||
"statsDeaths": "死亡回数",
|
||||
"statsDamage": "ダメージ",
|
||||
"statsDamageDealt": "与えたダメージ",
|
||||
"statsDamageTaken": "受けたダメージ",
|
||||
"statsAverageDps": "平均DPS",
|
||||
"statsSkills": "スキル",
|
||||
"statsSkillsUsed": "スキル使用",
|
||||
"statsCriticalHits": "クリティカルヒット",
|
||||
"statsMaxCriticalStreak": "最大連続クリティカル",
|
||||
"statsCriticalRate": "クリティカル率",
|
||||
"statsEconomy": "経済",
|
||||
"statsGoldEarned": "獲得ゴールド",
|
||||
"statsGoldSpent": "消費ゴールド",
|
||||
"statsItemsSold": "売却アイテム",
|
||||
"statsPotionsUsed": "ポーション使用",
|
||||
"statsProgress": "進行",
|
||||
"statsLevelUps": "レベルアップ",
|
||||
"statsQuestsCompleted": "完了したクエスト",
|
||||
"statsRecords": "記録",
|
||||
"statsHighestLevel": "最高レベル",
|
||||
"statsHighestGoldHeld": "最大所持ゴールド",
|
||||
"statsBestCriticalStreak": "最高連続クリティカル",
|
||||
"statsTotalPlay": "総プレイ",
|
||||
"statsTotalPlayTime": "総プレイ時間",
|
||||
"statsGamesStarted": "開始したゲーム",
|
||||
"statsGamesCompleted": "クリアしたゲーム",
|
||||
"statsCompletionRate": "クリア率",
|
||||
"statsTotalCombat": "総戦闘",
|
||||
"statsTotalDeaths": "総死亡",
|
||||
"statsTotalLevelUps": "総レベルアップ",
|
||||
"statsTotalDamage": "総ダメージ",
|
||||
"statsTotalSkills": "総スキル",
|
||||
"statsTotalEconomy": "総経済",
|
||||
|
||||
"notifyLevelUpLabel": "レベルアップ",
|
||||
"notifyQuestDoneLabel": "クエスト完了",
|
||||
"notifyActClearLabel": "幕完了",
|
||||
"notifyNewSpellLabel": "新しいスキル",
|
||||
"notifyNewItemLabel": "新しいアイテム",
|
||||
"notifyBossSlainLabel": "ボス撃破",
|
||||
"notifySavedLabel": "セーブ済み",
|
||||
"notifyInfoLabel": "情報",
|
||||
"notifyWarningLabel": "警告"
|
||||
}
|
||||
|
||||
@@ -145,5 +145,80 @@
|
||||
"debugIapPurchasedDesc": "ON: 유료 유저로 동작 (광고 제거)",
|
||||
"debugOfflineHours": "오프라인 시간",
|
||||
"debugOfflineHoursDesc": "복귀 보상 테스트 (재시작 시 적용)",
|
||||
"debugTestCharacterDesc": "현재 캐릭터를 레벨 100으로 수정하여\n명예의 전당에 등록합니다."
|
||||
"debugTestCharacterDesc": "현재 캐릭터를 레벨 100으로 수정하여\n명예의 전당에 등록합니다.",
|
||||
|
||||
"arenaTitle": "로컬 아레나",
|
||||
"arenaSelectFighter": "전사를 선택하세요",
|
||||
"arenaEmptyTitle": "영웅이 부족합니다",
|
||||
"arenaEmptyHint": "2명 이상 캐릭터로 클리어하세요",
|
||||
"arenaSetupTitle": "아레나 설정",
|
||||
"arenaStartBattle": "전투 시작",
|
||||
"arenaBattleTitle": "아레나 전투",
|
||||
"arenaMyEquipment": "내 장비",
|
||||
"arenaEnemyEquipment": "상대 장비",
|
||||
"arenaSelected": "선택됨",
|
||||
"arenaRecommended": "추천",
|
||||
"arenaWeaponLocked": "잠김",
|
||||
"arenaVictory": "승리!",
|
||||
"arenaDefeat": "패배...",
|
||||
"arenaEquipmentExchange": "장비 교환",
|
||||
"arenaTurns": "턴",
|
||||
"arenaWinner": "승자",
|
||||
"arenaLoser": "패자",
|
||||
"arenaDefeatedIn": "{winner}이(가) {loser}을(를) {turns}턴 만에 격파",
|
||||
"arenaScoreGain": "+{score} 획득 예정",
|
||||
"arenaScoreLose": "{score} 손실 예정",
|
||||
"arenaEvenTrade": "등가 교환",
|
||||
"arenaScore": "점수",
|
||||
|
||||
"statsStatistics": "통계",
|
||||
"statsSession": "세션",
|
||||
"statsAccumulated": "누적",
|
||||
"statsCombat": "전투",
|
||||
"statsPlayTime": "플레이 시간",
|
||||
"statsMonstersKilled": "처치한 몬스터",
|
||||
"statsBossesDefeated": "보스 처치",
|
||||
"statsDeaths": "사망 횟수",
|
||||
"statsDamage": "데미지",
|
||||
"statsDamageDealt": "입힌 데미지",
|
||||
"statsDamageTaken": "받은 데미지",
|
||||
"statsAverageDps": "평균 DPS",
|
||||
"statsSkills": "스킬",
|
||||
"statsSkillsUsed": "스킬 사용",
|
||||
"statsCriticalHits": "크리티컬 히트",
|
||||
"statsMaxCriticalStreak": "최대 연속 크리티컬",
|
||||
"statsCriticalRate": "크리티컬 비율",
|
||||
"statsEconomy": "경제",
|
||||
"statsGoldEarned": "획득 골드",
|
||||
"statsGoldSpent": "소비 골드",
|
||||
"statsItemsSold": "판매 아이템",
|
||||
"statsPotionsUsed": "물약 사용",
|
||||
"statsProgress": "진행",
|
||||
"statsLevelUps": "레벨업",
|
||||
"statsQuestsCompleted": "완료한 퀘스트",
|
||||
"statsRecords": "기록",
|
||||
"statsHighestLevel": "최고 레벨",
|
||||
"statsHighestGoldHeld": "최대 보유 골드",
|
||||
"statsBestCriticalStreak": "최고 연속 크리티컬",
|
||||
"statsTotalPlay": "총 플레이",
|
||||
"statsTotalPlayTime": "총 플레이 시간",
|
||||
"statsGamesStarted": "시작한 게임",
|
||||
"statsGamesCompleted": "클리어한 게임",
|
||||
"statsCompletionRate": "클리어율",
|
||||
"statsTotalCombat": "총 전투",
|
||||
"statsTotalDeaths": "총 사망",
|
||||
"statsTotalLevelUps": "총 레벨업",
|
||||
"statsTotalDamage": "총 데미지",
|
||||
"statsTotalSkills": "총 스킬",
|
||||
"statsTotalEconomy": "총 경제",
|
||||
|
||||
"notifyLevelUpLabel": "레벨 업",
|
||||
"notifyQuestDoneLabel": "퀘스트 완료",
|
||||
"notifyActClearLabel": "막 완료",
|
||||
"notifyNewSpellLabel": "새 주문",
|
||||
"notifyNewItemLabel": "새 아이템",
|
||||
"notifyBossSlainLabel": "보스 처치",
|
||||
"notifySavedLabel": "저장됨",
|
||||
"notifyInfoLabel": "정보",
|
||||
"notifyWarningLabel": "경고"
|
||||
}
|
||||
|
||||
@@ -956,6 +956,438 @@ abstract class L10n {
|
||||
/// In en, this message translates to:
|
||||
/// **'Modify current character to Level 100\nand register to the Hall of Fame.'**
|
||||
String get debugTestCharacterDesc;
|
||||
|
||||
/// Arena main screen title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'LOCAL ARENA'**
|
||||
String get arenaTitle;
|
||||
|
||||
/// Arena character selection subtitle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SELECT YOUR FIGHTER'**
|
||||
String get arenaSelectFighter;
|
||||
|
||||
/// Arena empty state title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not enough heroes'**
|
||||
String get arenaEmptyTitle;
|
||||
|
||||
/// Arena empty state hint
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear the game with 2+ characters'**
|
||||
String get arenaEmptyHint;
|
||||
|
||||
/// Arena setup screen title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ARENA SETUP'**
|
||||
String get arenaSetupTitle;
|
||||
|
||||
/// Start battle button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'START BATTLE'**
|
||||
String get arenaStartBattle;
|
||||
|
||||
/// Arena battle screen title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ARENA BATTLE'**
|
||||
String get arenaBattleTitle;
|
||||
|
||||
/// My equipment header
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'MY EQUIPMENT'**
|
||||
String get arenaMyEquipment;
|
||||
|
||||
/// Enemy equipment header
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ENEMY EQUIPMENT'**
|
||||
String get arenaEnemyEquipment;
|
||||
|
||||
/// Selected slot label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SELECTED'**
|
||||
String get arenaSelected;
|
||||
|
||||
/// Recommended slot label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'BEST'**
|
||||
String get arenaRecommended;
|
||||
|
||||
/// Weapon slot locked label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'LOCKED'**
|
||||
String get arenaWeaponLocked;
|
||||
|
||||
/// Arena victory title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'VICTORY!'**
|
||||
String get arenaVictory;
|
||||
|
||||
/// Arena defeat title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'DEFEAT...'**
|
||||
String get arenaDefeat;
|
||||
|
||||
/// Equipment exchange section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'EQUIPMENT EXCHANGE'**
|
||||
String get arenaEquipmentExchange;
|
||||
|
||||
/// Turns label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'TURNS'**
|
||||
String get arenaTurns;
|
||||
|
||||
/// Winner label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'WINNER'**
|
||||
String get arenaWinner;
|
||||
|
||||
/// Loser label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'LOSER'**
|
||||
String get arenaLoser;
|
||||
|
||||
/// Battle summary text
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{winner} defeated {loser} in {turns} TURNS'**
|
||||
String arenaDefeatedIn(String winner, String loser, int turns);
|
||||
|
||||
/// Score gain prediction
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You will GAIN +{score}'**
|
||||
String arenaScoreGain(int score);
|
||||
|
||||
/// Score loss prediction
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You will LOSE {score}'**
|
||||
String arenaScoreLose(int score);
|
||||
|
||||
/// Even trade label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Even trade'**
|
||||
String get arenaEvenTrade;
|
||||
|
||||
/// Score label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SCORE'**
|
||||
String get arenaScore;
|
||||
|
||||
/// Statistics dialog title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Statistics'**
|
||||
String get statsStatistics;
|
||||
|
||||
/// Session tab label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Session'**
|
||||
String get statsSession;
|
||||
|
||||
/// Accumulated tab label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total'**
|
||||
String get statsAccumulated;
|
||||
|
||||
/// Combat section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Combat'**
|
||||
String get statsCombat;
|
||||
|
||||
/// Play time label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Play Time'**
|
||||
String get statsPlayTime;
|
||||
|
||||
/// Monsters killed label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Monsters Killed'**
|
||||
String get statsMonstersKilled;
|
||||
|
||||
/// Bosses defeated label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bosses Defeated'**
|
||||
String get statsBossesDefeated;
|
||||
|
||||
/// Deaths label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Deaths'**
|
||||
String get statsDeaths;
|
||||
|
||||
/// Damage section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Damage'**
|
||||
String get statsDamage;
|
||||
|
||||
/// Damage dealt label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Damage Dealt'**
|
||||
String get statsDamageDealt;
|
||||
|
||||
/// Damage taken label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Damage Taken'**
|
||||
String get statsDamageTaken;
|
||||
|
||||
/// Average DPS label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Average DPS'**
|
||||
String get statsAverageDps;
|
||||
|
||||
/// Skills section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Skills'**
|
||||
String get statsSkills;
|
||||
|
||||
/// Skills used label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Skills Used'**
|
||||
String get statsSkillsUsed;
|
||||
|
||||
/// Critical hits label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Critical Hits'**
|
||||
String get statsCriticalHits;
|
||||
|
||||
/// Max critical streak label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Max Critical Streak'**
|
||||
String get statsMaxCriticalStreak;
|
||||
|
||||
/// Critical rate label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Critical Rate'**
|
||||
String get statsCriticalRate;
|
||||
|
||||
/// Economy section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Economy'**
|
||||
String get statsEconomy;
|
||||
|
||||
/// Gold earned label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Gold Earned'**
|
||||
String get statsGoldEarned;
|
||||
|
||||
/// Gold spent label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Gold Spent'**
|
||||
String get statsGoldSpent;
|
||||
|
||||
/// Items sold label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Items Sold'**
|
||||
String get statsItemsSold;
|
||||
|
||||
/// Potions used label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Potions Used'**
|
||||
String get statsPotionsUsed;
|
||||
|
||||
/// Progress section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Progress'**
|
||||
String get statsProgress;
|
||||
|
||||
/// Level ups label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Level Ups'**
|
||||
String get statsLevelUps;
|
||||
|
||||
/// Quests completed label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Quests Completed'**
|
||||
String get statsQuestsCompleted;
|
||||
|
||||
/// Records section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Records'**
|
||||
String get statsRecords;
|
||||
|
||||
/// Highest level label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Highest Level'**
|
||||
String get statsHighestLevel;
|
||||
|
||||
/// Highest gold held label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Highest Gold Held'**
|
||||
String get statsHighestGoldHeld;
|
||||
|
||||
/// Best critical streak label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best Critical Streak'**
|
||||
String get statsBestCriticalStreak;
|
||||
|
||||
/// Total play section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Play'**
|
||||
String get statsTotalPlay;
|
||||
|
||||
/// Total play time label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Play Time'**
|
||||
String get statsTotalPlayTime;
|
||||
|
||||
/// Games started label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Games Started'**
|
||||
String get statsGamesStarted;
|
||||
|
||||
/// Games completed label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Games Completed'**
|
||||
String get statsGamesCompleted;
|
||||
|
||||
/// Completion rate label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Completion Rate'**
|
||||
String get statsCompletionRate;
|
||||
|
||||
/// Total combat section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Combat'**
|
||||
String get statsTotalCombat;
|
||||
|
||||
/// Total deaths label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Deaths'**
|
||||
String get statsTotalDeaths;
|
||||
|
||||
/// Total level ups label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Level Ups'**
|
||||
String get statsTotalLevelUps;
|
||||
|
||||
/// Total damage section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Damage'**
|
||||
String get statsTotalDamage;
|
||||
|
||||
/// Total skills section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Skills'**
|
||||
String get statsTotalSkills;
|
||||
|
||||
/// Total economy section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Economy'**
|
||||
String get statsTotalEconomy;
|
||||
|
||||
/// Level up notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'LEVEL UP'**
|
||||
String get notifyLevelUpLabel;
|
||||
|
||||
/// Quest done notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'QUEST DONE'**
|
||||
String get notifyQuestDoneLabel;
|
||||
|
||||
/// Act clear notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ACT CLEAR'**
|
||||
String get notifyActClearLabel;
|
||||
|
||||
/// New spell notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'NEW SPELL'**
|
||||
String get notifyNewSpellLabel;
|
||||
|
||||
/// New item notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'NEW ITEM'**
|
||||
String get notifyNewItemLabel;
|
||||
|
||||
/// Boss slain notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'BOSS SLAIN'**
|
||||
String get notifyBossSlainLabel;
|
||||
|
||||
/// Game saved notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SAVED'**
|
||||
String get notifySavedLabel;
|
||||
|
||||
/// Info notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'INFO'**
|
||||
String get notifyInfoLabel;
|
||||
|
||||
/// Warning notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'WARNING'**
|
||||
String get notifyWarningLabel;
|
||||
}
|
||||
|
||||
class _L10nDelegate extends LocalizationsDelegate<L10n> {
|
||||
|
||||
@@ -458,4 +458,226 @@ class L10nEn extends L10n {
|
||||
@override
|
||||
String get debugTestCharacterDesc =>
|
||||
'Modify current character to Level 100\nand register to the Hall of Fame.';
|
||||
|
||||
@override
|
||||
String get arenaTitle => 'LOCAL ARENA';
|
||||
|
||||
@override
|
||||
String get arenaSelectFighter => 'SELECT YOUR FIGHTER';
|
||||
|
||||
@override
|
||||
String get arenaEmptyTitle => 'Not enough heroes';
|
||||
|
||||
@override
|
||||
String get arenaEmptyHint => 'Clear the game with 2+ characters';
|
||||
|
||||
@override
|
||||
String get arenaSetupTitle => 'ARENA SETUP';
|
||||
|
||||
@override
|
||||
String get arenaStartBattle => 'START BATTLE';
|
||||
|
||||
@override
|
||||
String get arenaBattleTitle => 'ARENA BATTLE';
|
||||
|
||||
@override
|
||||
String get arenaMyEquipment => 'MY EQUIPMENT';
|
||||
|
||||
@override
|
||||
String get arenaEnemyEquipment => 'ENEMY EQUIPMENT';
|
||||
|
||||
@override
|
||||
String get arenaSelected => 'SELECTED';
|
||||
|
||||
@override
|
||||
String get arenaRecommended => 'BEST';
|
||||
|
||||
@override
|
||||
String get arenaWeaponLocked => 'LOCKED';
|
||||
|
||||
@override
|
||||
String get arenaVictory => 'VICTORY!';
|
||||
|
||||
@override
|
||||
String get arenaDefeat => 'DEFEAT...';
|
||||
|
||||
@override
|
||||
String get arenaEquipmentExchange => 'EQUIPMENT EXCHANGE';
|
||||
|
||||
@override
|
||||
String get arenaTurns => 'TURNS';
|
||||
|
||||
@override
|
||||
String get arenaWinner => 'WINNER';
|
||||
|
||||
@override
|
||||
String get arenaLoser => 'LOSER';
|
||||
|
||||
@override
|
||||
String arenaDefeatedIn(String winner, String loser, int turns) {
|
||||
return '$winner defeated $loser in $turns TURNS';
|
||||
}
|
||||
|
||||
@override
|
||||
String arenaScoreGain(int score) {
|
||||
return 'You will GAIN +$score';
|
||||
}
|
||||
|
||||
@override
|
||||
String arenaScoreLose(int score) {
|
||||
return 'You will LOSE $score';
|
||||
}
|
||||
|
||||
@override
|
||||
String get arenaEvenTrade => 'Even trade';
|
||||
|
||||
@override
|
||||
String get arenaScore => 'SCORE';
|
||||
|
||||
@override
|
||||
String get statsStatistics => 'Statistics';
|
||||
|
||||
@override
|
||||
String get statsSession => 'Session';
|
||||
|
||||
@override
|
||||
String get statsAccumulated => 'Total';
|
||||
|
||||
@override
|
||||
String get statsCombat => 'Combat';
|
||||
|
||||
@override
|
||||
String get statsPlayTime => 'Play Time';
|
||||
|
||||
@override
|
||||
String get statsMonstersKilled => 'Monsters Killed';
|
||||
|
||||
@override
|
||||
String get statsBossesDefeated => 'Bosses Defeated';
|
||||
|
||||
@override
|
||||
String get statsDeaths => 'Deaths';
|
||||
|
||||
@override
|
||||
String get statsDamage => 'Damage';
|
||||
|
||||
@override
|
||||
String get statsDamageDealt => 'Damage Dealt';
|
||||
|
||||
@override
|
||||
String get statsDamageTaken => 'Damage Taken';
|
||||
|
||||
@override
|
||||
String get statsAverageDps => 'Average DPS';
|
||||
|
||||
@override
|
||||
String get statsSkills => 'Skills';
|
||||
|
||||
@override
|
||||
String get statsSkillsUsed => 'Skills Used';
|
||||
|
||||
@override
|
||||
String get statsCriticalHits => 'Critical Hits';
|
||||
|
||||
@override
|
||||
String get statsMaxCriticalStreak => 'Max Critical Streak';
|
||||
|
||||
@override
|
||||
String get statsCriticalRate => 'Critical Rate';
|
||||
|
||||
@override
|
||||
String get statsEconomy => 'Economy';
|
||||
|
||||
@override
|
||||
String get statsGoldEarned => 'Gold Earned';
|
||||
|
||||
@override
|
||||
String get statsGoldSpent => 'Gold Spent';
|
||||
|
||||
@override
|
||||
String get statsItemsSold => 'Items Sold';
|
||||
|
||||
@override
|
||||
String get statsPotionsUsed => 'Potions Used';
|
||||
|
||||
@override
|
||||
String get statsProgress => 'Progress';
|
||||
|
||||
@override
|
||||
String get statsLevelUps => 'Level Ups';
|
||||
|
||||
@override
|
||||
String get statsQuestsCompleted => 'Quests Completed';
|
||||
|
||||
@override
|
||||
String get statsRecords => 'Records';
|
||||
|
||||
@override
|
||||
String get statsHighestLevel => 'Highest Level';
|
||||
|
||||
@override
|
||||
String get statsHighestGoldHeld => 'Highest Gold Held';
|
||||
|
||||
@override
|
||||
String get statsBestCriticalStreak => 'Best Critical Streak';
|
||||
|
||||
@override
|
||||
String get statsTotalPlay => 'Total Play';
|
||||
|
||||
@override
|
||||
String get statsTotalPlayTime => 'Total Play Time';
|
||||
|
||||
@override
|
||||
String get statsGamesStarted => 'Games Started';
|
||||
|
||||
@override
|
||||
String get statsGamesCompleted => 'Games Completed';
|
||||
|
||||
@override
|
||||
String get statsCompletionRate => 'Completion Rate';
|
||||
|
||||
@override
|
||||
String get statsTotalCombat => 'Total Combat';
|
||||
|
||||
@override
|
||||
String get statsTotalDeaths => 'Total Deaths';
|
||||
|
||||
@override
|
||||
String get statsTotalLevelUps => 'Total Level Ups';
|
||||
|
||||
@override
|
||||
String get statsTotalDamage => 'Total Damage';
|
||||
|
||||
@override
|
||||
String get statsTotalSkills => 'Total Skills';
|
||||
|
||||
@override
|
||||
String get statsTotalEconomy => 'Total Economy';
|
||||
|
||||
@override
|
||||
String get notifyLevelUpLabel => 'LEVEL UP';
|
||||
|
||||
@override
|
||||
String get notifyQuestDoneLabel => 'QUEST DONE';
|
||||
|
||||
@override
|
||||
String get notifyActClearLabel => 'ACT CLEAR';
|
||||
|
||||
@override
|
||||
String get notifyNewSpellLabel => 'NEW SPELL';
|
||||
|
||||
@override
|
||||
String get notifyNewItemLabel => 'NEW ITEM';
|
||||
|
||||
@override
|
||||
String get notifyBossSlainLabel => 'BOSS SLAIN';
|
||||
|
||||
@override
|
||||
String get notifySavedLabel => 'SAVED';
|
||||
|
||||
@override
|
||||
String get notifyInfoLabel => 'INFO';
|
||||
|
||||
@override
|
||||
String get notifyWarningLabel => 'WARNING';
|
||||
}
|
||||
|
||||
@@ -12,75 +12,75 @@ class L10nJa extends L10n {
|
||||
String get appTitle => 'アスキー ネバー ダイ';
|
||||
|
||||
@override
|
||||
String get tagNoNetwork => 'No network';
|
||||
String get tagNoNetwork => 'オフライン';
|
||||
|
||||
@override
|
||||
String get tagIdleRpg => 'Idle RPG loop';
|
||||
String get tagIdleRpg => '放置型RPG';
|
||||
|
||||
@override
|
||||
String get tagLocalSaves => 'Local saves';
|
||||
String get tagLocalSaves => 'ローカル保存';
|
||||
|
||||
@override
|
||||
String get newCharacter => 'New character';
|
||||
String get newCharacter => '新規キャラクター';
|
||||
|
||||
@override
|
||||
String get loadSave => 'Load save';
|
||||
String get loadSave => 'ロード';
|
||||
|
||||
@override
|
||||
String get loadGame => 'Load Game';
|
||||
String get loadGame => 'ゲームをロード';
|
||||
|
||||
@override
|
||||
String get viewBuildPlan => 'View build plan';
|
||||
String get viewBuildPlan => 'ビルド計画を見る';
|
||||
|
||||
@override
|
||||
String get buildRoadmap => 'Build roadmap';
|
||||
String get buildRoadmap => 'ビルドロードマップ';
|
||||
|
||||
@override
|
||||
String get techStack => 'Tech stack';
|
||||
String get techStack => '技術スタック';
|
||||
|
||||
@override
|
||||
String get cancel => 'Cancel';
|
||||
String get cancel => 'キャンセル';
|
||||
|
||||
@override
|
||||
String get exitGame => 'Exit Game';
|
||||
String get exitGame => 'ゲーム終了';
|
||||
|
||||
@override
|
||||
String get saveProgressQuestion => 'Save your progress before leaving?';
|
||||
String get saveProgressQuestion => '終了する前にセーブしますか?';
|
||||
|
||||
@override
|
||||
String get exitWithoutSaving => 'Exit without saving';
|
||||
String get exitWithoutSaving => 'セーブせずに終了';
|
||||
|
||||
@override
|
||||
String get saveAndExit => 'Save and Exit';
|
||||
String get saveAndExit => 'セーブして終了';
|
||||
|
||||
@override
|
||||
String progressQuestTitle(String name) {
|
||||
return 'ASCII NEVER DIE - $name';
|
||||
return 'アスキー ネバー ダイ - $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get levelUp => 'Level Up';
|
||||
String get levelUp => 'レベルアップ';
|
||||
|
||||
@override
|
||||
String get completeQuest => 'Complete Quest';
|
||||
String get completeQuest => 'クエスト完了';
|
||||
|
||||
@override
|
||||
String get completePlot => 'Complete Plot';
|
||||
String get completePlot => 'プロット完了';
|
||||
|
||||
@override
|
||||
String get characterSheet => 'Character Sheet';
|
||||
String get characterSheet => 'キャラクターシート';
|
||||
|
||||
@override
|
||||
String get traits => 'Traits';
|
||||
String get traits => '特性';
|
||||
|
||||
@override
|
||||
String get stats => 'Stats';
|
||||
String get stats => '能力値';
|
||||
|
||||
@override
|
||||
String get experience => 'Experience';
|
||||
String get experience => '経験値';
|
||||
|
||||
@override
|
||||
String get xpNeededForNextLevel => 'XP needed for next level';
|
||||
String get xpNeededForNextLevel => '次のレベルまでの必要XP';
|
||||
|
||||
@override
|
||||
String get spellBook => 'スキル';
|
||||
@@ -89,34 +89,34 @@ class L10nJa extends L10n {
|
||||
String get noSpellsYet => '習得したスキルがありません';
|
||||
|
||||
@override
|
||||
String get equipment => 'Equipment';
|
||||
String get equipment => '装備';
|
||||
|
||||
@override
|
||||
String get inventory => 'Inventory';
|
||||
String get inventory => 'インベントリ';
|
||||
|
||||
@override
|
||||
String get encumbrance => 'Encumbrance';
|
||||
String get encumbrance => '積載量';
|
||||
|
||||
@override
|
||||
String get combatLog => '戦闘ログ';
|
||||
|
||||
@override
|
||||
String get plotDevelopment => 'Plot Development';
|
||||
String get plotDevelopment => 'ストーリー進行';
|
||||
|
||||
@override
|
||||
String get quests => 'Quests';
|
||||
String get quests => 'クエスト';
|
||||
|
||||
@override
|
||||
String get traitName => 'Name';
|
||||
String get traitName => '名前';
|
||||
|
||||
@override
|
||||
String get traitRace => 'Race';
|
||||
String get traitRace => '種族';
|
||||
|
||||
@override
|
||||
String get traitClass => 'Class';
|
||||
String get traitClass => '職業';
|
||||
|
||||
@override
|
||||
String get traitLevel => 'Level';
|
||||
String get traitLevel => 'レベル';
|
||||
|
||||
@override
|
||||
String get statStr => 'STR';
|
||||
@@ -137,43 +137,43 @@ class L10nJa extends L10n {
|
||||
String get statCha => 'CHA';
|
||||
|
||||
@override
|
||||
String get statHpMax => 'HP Max';
|
||||
String get statHpMax => 'HP最大';
|
||||
|
||||
@override
|
||||
String get statMpMax => 'MP Max';
|
||||
String get statMpMax => 'MP最大';
|
||||
|
||||
@override
|
||||
String get equipWeapon => 'Weapon';
|
||||
String get equipWeapon => '武器';
|
||||
|
||||
@override
|
||||
String get equipShield => 'Shield';
|
||||
String get equipShield => '盾';
|
||||
|
||||
@override
|
||||
String get equipHelm => 'Helm';
|
||||
String get equipHelm => '兜';
|
||||
|
||||
@override
|
||||
String get equipHauberk => 'Hauberk';
|
||||
String get equipHauberk => '鎧';
|
||||
|
||||
@override
|
||||
String get equipBrassairts => 'Brassairts';
|
||||
String get equipBrassairts => '肩当て';
|
||||
|
||||
@override
|
||||
String get equipVambraces => 'Vambraces';
|
||||
String get equipVambraces => '腕甲';
|
||||
|
||||
@override
|
||||
String get equipGauntlets => 'Gauntlets';
|
||||
String get equipGauntlets => '篭手';
|
||||
|
||||
@override
|
||||
String get equipGambeson => 'Gambeson';
|
||||
String get equipGambeson => '防護服';
|
||||
|
||||
@override
|
||||
String get equipCuisses => 'Cuisses';
|
||||
String get equipCuisses => '腿当て';
|
||||
|
||||
@override
|
||||
String get equipGreaves => 'Greaves';
|
||||
String get equipGreaves => '脛当て';
|
||||
|
||||
@override
|
||||
String get equipSollerets => 'Sollerets';
|
||||
String get equipSollerets => '鉄靴';
|
||||
|
||||
@override
|
||||
String get gold => 'コイン';
|
||||
@@ -184,63 +184,63 @@ class L10nJa extends L10n {
|
||||
}
|
||||
|
||||
@override
|
||||
String get prologue => 'Prologue';
|
||||
String get prologue => 'プロローグ';
|
||||
|
||||
@override
|
||||
String actNumber(String number) {
|
||||
return 'Act $number';
|
||||
return '第$number幕';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noActiveQuests => 'No active quests';
|
||||
String get noActiveQuests => '進行中のクエストなし';
|
||||
|
||||
@override
|
||||
String questNumber(int number) {
|
||||
return 'Quest #$number';
|
||||
return 'クエスト #$number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'ASCII NEVER DIEへようこそ!';
|
||||
|
||||
@override
|
||||
String get noSavedGames => 'No saved games found.';
|
||||
String get noSavedGames => 'セーブデータがありません。';
|
||||
|
||||
@override
|
||||
String loadError(String error) {
|
||||
return 'Failed to load save file: $error';
|
||||
return 'セーブファイルの読み込みに失敗しました: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get name => 'Name';
|
||||
String get name => '名前';
|
||||
|
||||
@override
|
||||
String get generateName => 'Generate Name';
|
||||
String get generateName => '名前を生成';
|
||||
|
||||
@override
|
||||
String get total => 'Total';
|
||||
String get total => '合計';
|
||||
|
||||
@override
|
||||
String get unroll => '元に戻す';
|
||||
|
||||
@override
|
||||
String get roll => 'Roll';
|
||||
String get roll => 'ロール';
|
||||
|
||||
@override
|
||||
String get race => 'Race';
|
||||
String get race => '種族';
|
||||
|
||||
@override
|
||||
String get classTitle => 'Class';
|
||||
String get classTitle => '職業';
|
||||
|
||||
@override
|
||||
String percentComplete(int percent) {
|
||||
return '$percent% complete';
|
||||
return '$percent% 完了';
|
||||
}
|
||||
|
||||
@override
|
||||
String get newCharacterTitle => 'ASCII NEVER DIE - New Character';
|
||||
String get newCharacterTitle => 'アスキー ネバー ダイ - 新規キャラクター';
|
||||
|
||||
@override
|
||||
String get soldButton => 'Sold!';
|
||||
String get soldButton => '決定!';
|
||||
|
||||
@override
|
||||
String get endingCongratulations => '★ おめでとうございます ★';
|
||||
@@ -299,55 +299,55 @@ class L10nJa extends L10n {
|
||||
String get endingHoldToSpeedUp => '長押しで高速スクロール';
|
||||
|
||||
@override
|
||||
String get menuTitle => 'MENU';
|
||||
String get menuTitle => 'メニュー';
|
||||
|
||||
@override
|
||||
String get optionsTitle => 'OPTIONS';
|
||||
String get optionsTitle => 'オプション';
|
||||
|
||||
@override
|
||||
String get soundTitle => 'SOUND';
|
||||
String get soundTitle => 'サウンド';
|
||||
|
||||
@override
|
||||
String get controlSection => 'CONTROL';
|
||||
String get controlSection => '操作';
|
||||
|
||||
@override
|
||||
String get infoSection => 'INFO';
|
||||
String get infoSection => '情報';
|
||||
|
||||
@override
|
||||
String get settingsSection => 'SETTINGS';
|
||||
String get settingsSection => '設定';
|
||||
|
||||
@override
|
||||
String get saveExitSection => 'SAVE / EXIT';
|
||||
String get saveExitSection => 'セーブ / 終了';
|
||||
|
||||
@override
|
||||
String get ok => 'OK';
|
||||
|
||||
@override
|
||||
String get rechargeButton => 'RECHARGE';
|
||||
String get rechargeButton => 'チャージ';
|
||||
|
||||
@override
|
||||
String get createButton => 'CREATE';
|
||||
String get createButton => '作成';
|
||||
|
||||
@override
|
||||
String get previewTitle => 'PREVIEW';
|
||||
String get previewTitle => 'プレビュー';
|
||||
|
||||
@override
|
||||
String get nameTitle => 'NAME';
|
||||
String get nameTitle => '名前';
|
||||
|
||||
@override
|
||||
String get statsTitle => 'STATS';
|
||||
String get statsTitle => '能力値';
|
||||
|
||||
@override
|
||||
String get raceTitle => 'RACE';
|
||||
String get raceTitle => '種族';
|
||||
|
||||
@override
|
||||
String get classSection => 'CLASS';
|
||||
String get classSection => '職業';
|
||||
|
||||
@override
|
||||
String get bgmLabel => 'BGM';
|
||||
|
||||
@override
|
||||
String get sfxLabel => 'SFX';
|
||||
String get sfxLabel => '効果音';
|
||||
|
||||
@override
|
||||
String get hpLabel => 'HP';
|
||||
@@ -359,103 +359,322 @@ class L10nJa extends L10n {
|
||||
String get expLabel => 'EXP';
|
||||
|
||||
@override
|
||||
String get notifyLevelUp => 'LEVEL UP!';
|
||||
String get notifyLevelUp => 'レベルアップ!';
|
||||
|
||||
@override
|
||||
String notifyLevel(int level) {
|
||||
return 'Level $level';
|
||||
return 'レベル $level';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notifyQuestComplete => 'QUEST COMPLETE!';
|
||||
String get notifyQuestComplete => 'クエスト完了!';
|
||||
|
||||
@override
|
||||
String get notifyPrologueComplete => 'PROLOGUE COMPLETE!';
|
||||
String get notifyPrologueComplete => 'プロローグ完了!';
|
||||
|
||||
@override
|
||||
String notifyActComplete(int number) {
|
||||
return 'ACT $number COMPLETE!';
|
||||
return '第$number幕 完了!';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notifyNewSpell => 'NEW SPELL!';
|
||||
String get notifyNewSpell => '新しいスキル!';
|
||||
|
||||
@override
|
||||
String get notifyNewEquipment => 'NEW EQUIPMENT!';
|
||||
String get notifyNewEquipment => '新しい装備!';
|
||||
|
||||
@override
|
||||
String get notifyBossDefeated => 'BOSS DEFEATED!';
|
||||
String get notifyBossDefeated => 'ボス撃破!';
|
||||
|
||||
@override
|
||||
String get rechargeRollsTitle => 'RECHARGE ROLLS';
|
||||
String get rechargeRollsTitle => 'ロール回数チャージ';
|
||||
|
||||
@override
|
||||
String get rechargeRollsFree => 'Recharge 5 rolls for free?';
|
||||
String get rechargeRollsFree => '無料で5回チャージしますか?';
|
||||
|
||||
@override
|
||||
String get rechargeRollsAd => 'Watch an ad to recharge 5 rolls?';
|
||||
String get rechargeRollsAd => '広告を見て5回チャージしますか?';
|
||||
|
||||
@override
|
||||
String get debugTitle => 'DEBUG';
|
||||
String get debugTitle => 'デバッグ';
|
||||
|
||||
@override
|
||||
String get debugCheatsTitle => 'DEBUG CHEATS';
|
||||
String get debugCheatsTitle => 'デバッグチート';
|
||||
|
||||
@override
|
||||
String get debugToolsTitle => 'DEBUG TOOLS';
|
||||
String get debugToolsTitle => 'デバッグツール';
|
||||
|
||||
@override
|
||||
String get debugDeveloperTools => 'DEVELOPER TOOLS';
|
||||
String get debugDeveloperTools => '開発者ツール';
|
||||
|
||||
@override
|
||||
String get debugSkipTask => 'SKIP TASK (L+1)';
|
||||
String get debugSkipTask => 'タスクスキップ (L+1)';
|
||||
|
||||
@override
|
||||
String get debugSkipTaskDesc => 'Complete task instantly';
|
||||
String get debugSkipTaskDesc => 'タスクを即時完了';
|
||||
|
||||
@override
|
||||
String get debugSkipQuest => 'SKIP QUEST (Q!)';
|
||||
String get debugSkipQuest => 'クエストスキップ (Q!)';
|
||||
|
||||
@override
|
||||
String get debugSkipQuestDesc => 'Complete quest instantly';
|
||||
String get debugSkipQuestDesc => 'クエストを即時完了';
|
||||
|
||||
@override
|
||||
String get debugSkipAct => 'SKIP ACT (P!)';
|
||||
String get debugSkipAct => 'アクトスキップ (P!)';
|
||||
|
||||
@override
|
||||
String get debugSkipActDesc => 'Complete act instantly';
|
||||
String get debugSkipActDesc => 'アクトを即時完了';
|
||||
|
||||
@override
|
||||
String get debugCreateTestCharacter => 'CREATE TEST CHARACTER';
|
||||
String get debugCreateTestCharacter => 'テストキャラクター作成';
|
||||
|
||||
@override
|
||||
String get debugCreateTestCharacterDesc =>
|
||||
'Register Level 100 character to Hall of Fame';
|
||||
String get debugCreateTestCharacterDesc => 'レベル100キャラクターを殿堂に登録';
|
||||
|
||||
@override
|
||||
String get debugCreateTestCharacterTitle => 'CREATE TEST CHARACTER?';
|
||||
String get debugCreateTestCharacterTitle => 'テストキャラクターを作成しますか?';
|
||||
|
||||
@override
|
||||
String get debugCreateTestCharacterMessage =>
|
||||
'Current character will be converted to Level 100\nand registered to the Hall of Fame.\n\n⚠️ Current save file will be deleted.\nThis action cannot be undone.';
|
||||
'現在のキャラクターがレベル100に変換され\n殿堂に登録されます。\n\n⚠️ 現在のセーブファイルは削除されます。\nこの操作は元に戻せません。';
|
||||
|
||||
@override
|
||||
String get debugTurbo => 'DEBUG: TURBO (20x)';
|
||||
String get debugTurbo => 'デバッグ: ターボ (20x)';
|
||||
|
||||
@override
|
||||
String get debugIapPurchased => 'IAP PURCHASED';
|
||||
String get debugIapPurchased => 'IAP購入済み';
|
||||
|
||||
@override
|
||||
String get debugIapPurchasedDesc => 'ON: Behave as paid user (ads removed)';
|
||||
String get debugIapPurchasedDesc => 'ON: 有料ユーザーとして動作(広告非表示)';
|
||||
|
||||
@override
|
||||
String get debugOfflineHours => 'OFFLINE HOURS';
|
||||
String get debugOfflineHours => 'オフライン時間';
|
||||
|
||||
@override
|
||||
String get debugOfflineHoursDesc =>
|
||||
'Test return rewards (applies on restart)';
|
||||
String get debugOfflineHoursDesc => '復帰報酬テスト(再起動時に適用)';
|
||||
|
||||
@override
|
||||
String get debugTestCharacterDesc =>
|
||||
'Modify current character to Level 100\nand register to the Hall of Fame.';
|
||||
String get debugTestCharacterDesc => '現在のキャラクターをレベル100に変更して\n殿堂に登録します。';
|
||||
|
||||
@override
|
||||
String get arenaTitle => 'ローカルアリーナ';
|
||||
|
||||
@override
|
||||
String get arenaSelectFighter => 'ファイターを選択';
|
||||
|
||||
@override
|
||||
String get arenaEmptyTitle => 'ヒーローが不足しています';
|
||||
|
||||
@override
|
||||
String get arenaEmptyHint => '2人以上のキャラでクリアしてください';
|
||||
|
||||
@override
|
||||
String get arenaSetupTitle => 'アリーナ設定';
|
||||
|
||||
@override
|
||||
String get arenaStartBattle => 'バトル開始';
|
||||
|
||||
@override
|
||||
String get arenaBattleTitle => 'アリーナバトル';
|
||||
|
||||
@override
|
||||
String get arenaMyEquipment => '自分の装備';
|
||||
|
||||
@override
|
||||
String get arenaEnemyEquipment => '敵の装備';
|
||||
|
||||
@override
|
||||
String get arenaSelected => '選択済み';
|
||||
|
||||
@override
|
||||
String get arenaRecommended => 'おすすめ';
|
||||
|
||||
@override
|
||||
String get arenaWeaponLocked => 'ロック';
|
||||
|
||||
@override
|
||||
String get arenaVictory => '勝利!';
|
||||
|
||||
@override
|
||||
String get arenaDefeat => '敗北...';
|
||||
|
||||
@override
|
||||
String get arenaEquipmentExchange => '装備交換';
|
||||
|
||||
@override
|
||||
String get arenaTurns => 'ターン';
|
||||
|
||||
@override
|
||||
String get arenaWinner => '勝者';
|
||||
|
||||
@override
|
||||
String get arenaLoser => '敗者';
|
||||
|
||||
@override
|
||||
String arenaDefeatedIn(String winner, String loser, int turns) {
|
||||
return '$winnerが$loserを$turnsターンで撃破';
|
||||
}
|
||||
|
||||
@override
|
||||
String arenaScoreGain(int score) {
|
||||
return '+$score獲得予定';
|
||||
}
|
||||
|
||||
@override
|
||||
String arenaScoreLose(int score) {
|
||||
return '$score損失予定';
|
||||
}
|
||||
|
||||
@override
|
||||
String get arenaEvenTrade => '等価交換';
|
||||
|
||||
@override
|
||||
String get arenaScore => 'スコア';
|
||||
|
||||
@override
|
||||
String get statsStatistics => '統計';
|
||||
|
||||
@override
|
||||
String get statsSession => 'セッション';
|
||||
|
||||
@override
|
||||
String get statsAccumulated => '累積';
|
||||
|
||||
@override
|
||||
String get statsCombat => '戦闘';
|
||||
|
||||
@override
|
||||
String get statsPlayTime => 'プレイ時間';
|
||||
|
||||
@override
|
||||
String get statsMonstersKilled => '倒したモンスター';
|
||||
|
||||
@override
|
||||
String get statsBossesDefeated => 'ボス討伐';
|
||||
|
||||
@override
|
||||
String get statsDeaths => '死亡回数';
|
||||
|
||||
@override
|
||||
String get statsDamage => 'ダメージ';
|
||||
|
||||
@override
|
||||
String get statsDamageDealt => '与えたダメージ';
|
||||
|
||||
@override
|
||||
String get statsDamageTaken => '受けたダメージ';
|
||||
|
||||
@override
|
||||
String get statsAverageDps => '平均DPS';
|
||||
|
||||
@override
|
||||
String get statsSkills => 'スキル';
|
||||
|
||||
@override
|
||||
String get statsSkillsUsed => 'スキル使用';
|
||||
|
||||
@override
|
||||
String get statsCriticalHits => 'クリティカルヒット';
|
||||
|
||||
@override
|
||||
String get statsMaxCriticalStreak => '最大連続クリティカル';
|
||||
|
||||
@override
|
||||
String get statsCriticalRate => 'クリティカル率';
|
||||
|
||||
@override
|
||||
String get statsEconomy => '経済';
|
||||
|
||||
@override
|
||||
String get statsGoldEarned => '獲得ゴールド';
|
||||
|
||||
@override
|
||||
String get statsGoldSpent => '消費ゴールド';
|
||||
|
||||
@override
|
||||
String get statsItemsSold => '売却アイテム';
|
||||
|
||||
@override
|
||||
String get statsPotionsUsed => 'ポーション使用';
|
||||
|
||||
@override
|
||||
String get statsProgress => '進行';
|
||||
|
||||
@override
|
||||
String get statsLevelUps => 'レベルアップ';
|
||||
|
||||
@override
|
||||
String get statsQuestsCompleted => '完了したクエスト';
|
||||
|
||||
@override
|
||||
String get statsRecords => '記録';
|
||||
|
||||
@override
|
||||
String get statsHighestLevel => '最高レベル';
|
||||
|
||||
@override
|
||||
String get statsHighestGoldHeld => '最大所持ゴールド';
|
||||
|
||||
@override
|
||||
String get statsBestCriticalStreak => '最高連続クリティカル';
|
||||
|
||||
@override
|
||||
String get statsTotalPlay => '総プレイ';
|
||||
|
||||
@override
|
||||
String get statsTotalPlayTime => '総プレイ時間';
|
||||
|
||||
@override
|
||||
String get statsGamesStarted => '開始したゲーム';
|
||||
|
||||
@override
|
||||
String get statsGamesCompleted => 'クリアしたゲーム';
|
||||
|
||||
@override
|
||||
String get statsCompletionRate => 'クリア率';
|
||||
|
||||
@override
|
||||
String get statsTotalCombat => '総戦闘';
|
||||
|
||||
@override
|
||||
String get statsTotalDeaths => '総死亡';
|
||||
|
||||
@override
|
||||
String get statsTotalLevelUps => '総レベルアップ';
|
||||
|
||||
@override
|
||||
String get statsTotalDamage => '総ダメージ';
|
||||
|
||||
@override
|
||||
String get statsTotalSkills => '総スキル';
|
||||
|
||||
@override
|
||||
String get statsTotalEconomy => '総経済';
|
||||
|
||||
@override
|
||||
String get notifyLevelUpLabel => 'レベルアップ';
|
||||
|
||||
@override
|
||||
String get notifyQuestDoneLabel => 'クエスト完了';
|
||||
|
||||
@override
|
||||
String get notifyActClearLabel => '幕完了';
|
||||
|
||||
@override
|
||||
String get notifyNewSpellLabel => '新しいスキル';
|
||||
|
||||
@override
|
||||
String get notifyNewItemLabel => '新しいアイテム';
|
||||
|
||||
@override
|
||||
String get notifyBossSlainLabel => 'ボス撃破';
|
||||
|
||||
@override
|
||||
String get notifySavedLabel => 'セーブ済み';
|
||||
|
||||
@override
|
||||
String get notifyInfoLabel => '情報';
|
||||
|
||||
@override
|
||||
String get notifyWarningLabel => '警告';
|
||||
}
|
||||
|
||||
@@ -455,4 +455,226 @@ class L10nKo extends L10n {
|
||||
|
||||
@override
|
||||
String get debugTestCharacterDesc => '현재 캐릭터를 레벨 100으로 수정하여\n명예의 전당에 등록합니다.';
|
||||
|
||||
@override
|
||||
String get arenaTitle => '로컬 아레나';
|
||||
|
||||
@override
|
||||
String get arenaSelectFighter => '전사를 선택하세요';
|
||||
|
||||
@override
|
||||
String get arenaEmptyTitle => '영웅이 부족합니다';
|
||||
|
||||
@override
|
||||
String get arenaEmptyHint => '2명 이상 캐릭터로 클리어하세요';
|
||||
|
||||
@override
|
||||
String get arenaSetupTitle => '아레나 설정';
|
||||
|
||||
@override
|
||||
String get arenaStartBattle => '전투 시작';
|
||||
|
||||
@override
|
||||
String get arenaBattleTitle => '아레나 전투';
|
||||
|
||||
@override
|
||||
String get arenaMyEquipment => '내 장비';
|
||||
|
||||
@override
|
||||
String get arenaEnemyEquipment => '상대 장비';
|
||||
|
||||
@override
|
||||
String get arenaSelected => '선택됨';
|
||||
|
||||
@override
|
||||
String get arenaRecommended => '추천';
|
||||
|
||||
@override
|
||||
String get arenaWeaponLocked => '잠김';
|
||||
|
||||
@override
|
||||
String get arenaVictory => '승리!';
|
||||
|
||||
@override
|
||||
String get arenaDefeat => '패배...';
|
||||
|
||||
@override
|
||||
String get arenaEquipmentExchange => '장비 교환';
|
||||
|
||||
@override
|
||||
String get arenaTurns => '턴';
|
||||
|
||||
@override
|
||||
String get arenaWinner => '승자';
|
||||
|
||||
@override
|
||||
String get arenaLoser => '패자';
|
||||
|
||||
@override
|
||||
String arenaDefeatedIn(String winner, String loser, int turns) {
|
||||
return '$winner이(가) $loser을(를) $turns턴 만에 격파';
|
||||
}
|
||||
|
||||
@override
|
||||
String arenaScoreGain(int score) {
|
||||
return '+$score 획득 예정';
|
||||
}
|
||||
|
||||
@override
|
||||
String arenaScoreLose(int score) {
|
||||
return '$score 손실 예정';
|
||||
}
|
||||
|
||||
@override
|
||||
String get arenaEvenTrade => '등가 교환';
|
||||
|
||||
@override
|
||||
String get arenaScore => '점수';
|
||||
|
||||
@override
|
||||
String get statsStatistics => '통계';
|
||||
|
||||
@override
|
||||
String get statsSession => '세션';
|
||||
|
||||
@override
|
||||
String get statsAccumulated => '누적';
|
||||
|
||||
@override
|
||||
String get statsCombat => '전투';
|
||||
|
||||
@override
|
||||
String get statsPlayTime => '플레이 시간';
|
||||
|
||||
@override
|
||||
String get statsMonstersKilled => '처치한 몬스터';
|
||||
|
||||
@override
|
||||
String get statsBossesDefeated => '보스 처치';
|
||||
|
||||
@override
|
||||
String get statsDeaths => '사망 횟수';
|
||||
|
||||
@override
|
||||
String get statsDamage => '데미지';
|
||||
|
||||
@override
|
||||
String get statsDamageDealt => '입힌 데미지';
|
||||
|
||||
@override
|
||||
String get statsDamageTaken => '받은 데미지';
|
||||
|
||||
@override
|
||||
String get statsAverageDps => '평균 DPS';
|
||||
|
||||
@override
|
||||
String get statsSkills => '스킬';
|
||||
|
||||
@override
|
||||
String get statsSkillsUsed => '스킬 사용';
|
||||
|
||||
@override
|
||||
String get statsCriticalHits => '크리티컬 히트';
|
||||
|
||||
@override
|
||||
String get statsMaxCriticalStreak => '최대 연속 크리티컬';
|
||||
|
||||
@override
|
||||
String get statsCriticalRate => '크리티컬 비율';
|
||||
|
||||
@override
|
||||
String get statsEconomy => '경제';
|
||||
|
||||
@override
|
||||
String get statsGoldEarned => '획득 골드';
|
||||
|
||||
@override
|
||||
String get statsGoldSpent => '소비 골드';
|
||||
|
||||
@override
|
||||
String get statsItemsSold => '판매 아이템';
|
||||
|
||||
@override
|
||||
String get statsPotionsUsed => '물약 사용';
|
||||
|
||||
@override
|
||||
String get statsProgress => '진행';
|
||||
|
||||
@override
|
||||
String get statsLevelUps => '레벨업';
|
||||
|
||||
@override
|
||||
String get statsQuestsCompleted => '완료한 퀘스트';
|
||||
|
||||
@override
|
||||
String get statsRecords => '기록';
|
||||
|
||||
@override
|
||||
String get statsHighestLevel => '최고 레벨';
|
||||
|
||||
@override
|
||||
String get statsHighestGoldHeld => '최대 보유 골드';
|
||||
|
||||
@override
|
||||
String get statsBestCriticalStreak => '최고 연속 크리티컬';
|
||||
|
||||
@override
|
||||
String get statsTotalPlay => '총 플레이';
|
||||
|
||||
@override
|
||||
String get statsTotalPlayTime => '총 플레이 시간';
|
||||
|
||||
@override
|
||||
String get statsGamesStarted => '시작한 게임';
|
||||
|
||||
@override
|
||||
String get statsGamesCompleted => '클리어한 게임';
|
||||
|
||||
@override
|
||||
String get statsCompletionRate => '클리어율';
|
||||
|
||||
@override
|
||||
String get statsTotalCombat => '총 전투';
|
||||
|
||||
@override
|
||||
String get statsTotalDeaths => '총 사망';
|
||||
|
||||
@override
|
||||
String get statsTotalLevelUps => '총 레벨업';
|
||||
|
||||
@override
|
||||
String get statsTotalDamage => '총 데미지';
|
||||
|
||||
@override
|
||||
String get statsTotalSkills => '총 스킬';
|
||||
|
||||
@override
|
||||
String get statsTotalEconomy => '총 경제';
|
||||
|
||||
@override
|
||||
String get notifyLevelUpLabel => '레벨 업';
|
||||
|
||||
@override
|
||||
String get notifyQuestDoneLabel => '퀘스트 완료';
|
||||
|
||||
@override
|
||||
String get notifyActClearLabel => '막 완료';
|
||||
|
||||
@override
|
||||
String get notifyNewSpellLabel => '새 주문';
|
||||
|
||||
@override
|
||||
String get notifyNewItemLabel => '새 아이템';
|
||||
|
||||
@override
|
||||
String get notifyBossSlainLabel => '보스 처치';
|
||||
|
||||
@override
|
||||
String get notifySavedLabel => '저장됨';
|
||||
|
||||
@override
|
||||
String get notifyInfoLabel => '정보';
|
||||
|
||||
@override
|
||||
String get notifyWarningLabel => '경고';
|
||||
}
|
||||
|
||||
271
lib/src/app.dart
271
lib/src/app.dart
@@ -6,7 +6,8 @@ import 'package:asciineverdie/src/core/audio/audio_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/ad_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/debug_settings_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
import 'package:asciineverdie/src/app_theme.dart';
|
||||
import 'package:asciineverdie/src/splash_screen.dart';
|
||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
||||
@@ -221,140 +222,6 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp>
|
||||
}
|
||||
}
|
||||
|
||||
/// 앱 테마 (Dark Fantasy 스타일)
|
||||
ThemeData get _theme => ThemeData(
|
||||
colorScheme: RetroColors.darkColorScheme,
|
||||
scaffoldBackgroundColor: RetroColors.deepBrown,
|
||||
useMaterial3: true,
|
||||
// 카드/다이얼로그 레트로 배경
|
||||
cardColor: RetroColors.darkBrown,
|
||||
dialogTheme: const DialogThemeData(
|
||||
backgroundColor: Color(0xFF24283B),
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 15,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
),
|
||||
// 앱바 레트로 스타일
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Color(0xFF24283B),
|
||||
foregroundColor: Color(0xFFC0CAF5),
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 15,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
),
|
||||
// 버튼 테마 (inherit: false로 애니메이션 lerp 오류 방지)
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF3D4260),
|
||||
foregroundColor: const Color(0xFFC0CAF5),
|
||||
textStyle: const TextStyle(
|
||||
inherit: false,
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFE0AF68),
|
||||
side: const BorderSide(color: Color(0xFFE0AF68), width: 2),
|
||||
textStyle: const TextStyle(
|
||||
inherit: false,
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFC0CAF5),
|
||||
textStyle: const TextStyle(
|
||||
inherit: false,
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 텍스트 테마
|
||||
textTheme: const TextTheme(
|
||||
headlineLarge: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 20,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
headlineMedium: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 16,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
headlineSmall: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 15,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
titleLarge: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 15,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
titleSmall: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
bodyLarge: TextStyle(fontSize: 18, color: Color(0xFFC0CAF5)),
|
||||
bodyMedium: TextStyle(fontSize: 17, color: Color(0xFFC0CAF5)),
|
||||
bodySmall: TextStyle(fontSize: 15, color: Color(0xFFC0CAF5)),
|
||||
labelLarge: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
labelMedium: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
labelSmall: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
),
|
||||
// 칩 테마
|
||||
chipTheme: const ChipThemeData(
|
||||
backgroundColor: Color(0xFF2A2E3F),
|
||||
labelStyle: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
side: BorderSide(color: Color(0xFF545C7E)),
|
||||
),
|
||||
// 리스트 타일 테마
|
||||
listTileTheme: const ListTileThemeData(
|
||||
textColor: Color(0xFFC0CAF5),
|
||||
iconColor: Color(0xFFE0AF68),
|
||||
),
|
||||
// 프로그레스 인디케이터
|
||||
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
||||
color: Color(0xFFE0AF68),
|
||||
linearTrackColor: Color(0xFF3B4261),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
@@ -363,7 +230,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp>
|
||||
localizationsDelegates: L10n.localizationsDelegates,
|
||||
supportedLocales: L10n.supportedLocales,
|
||||
locale: _locale, // 사용자 선택 로케일 (null이면 시스템 기본값)
|
||||
theme: _theme,
|
||||
theme: buildAppTheme(),
|
||||
navigatorObservers: [_routeObserver],
|
||||
builder: (context, child) {
|
||||
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
|
||||
@@ -382,7 +249,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp>
|
||||
Widget _buildHomeScreen() {
|
||||
// 세이브 확인 중이면 로딩 스플래시 표시
|
||||
if (_isCheckingSave) {
|
||||
return const _SplashScreen();
|
||||
return const SplashScreen();
|
||||
}
|
||||
|
||||
return FrontScreen(
|
||||
@@ -591,133 +458,3 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp>
|
||||
}
|
||||
}
|
||||
|
||||
/// 스플래시 화면 (세이브 파일 확인 중) - 레트로 스타일
|
||||
class _SplashScreen extends StatelessWidget {
|
||||
const _SplashScreen();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: RetroColors.deepBrown,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 타이틀 로고
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.panelBg,
|
||||
border: Border.all(color: RetroColors.gold, width: 3),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 아이콘
|
||||
const Icon(
|
||||
Icons.auto_awesome,
|
||||
size: 32,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 타이틀
|
||||
const Text(
|
||||
'ASCII',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 22,
|
||||
color: RetroColors.gold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: RetroColors.goldDark,
|
||||
offset: Offset(2, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'NEVER DIE',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 16,
|
||||
color: RetroColors.cream,
|
||||
shadows: [
|
||||
Shadow(color: RetroColors.brown, offset: Offset(1, 1)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// 레트로 로딩 바
|
||||
SizedBox(width: 160, child: _RetroLoadingBar()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 레트로 스타일 로딩 바 (애니메이션)
|
||||
class _RetroLoadingBar extends StatefulWidget {
|
||||
@override
|
||||
State<_RetroLoadingBar> createState() => _RetroLoadingBarState();
|
||||
}
|
||||
|
||||
class _RetroLoadingBarState extends State<_RetroLoadingBar>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
vsync: this,
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const segmentCount = 10;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
// 웨이브 효과: 각 세그먼트가 순차적으로 켜지고 꺼짐
|
||||
return Container(
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.panelBg,
|
||||
border: Border.all(color: RetroColors.panelBorderOuter, width: 2),
|
||||
),
|
||||
child: Row(
|
||||
children: List.generate(segmentCount, (index) {
|
||||
// 웨이브 패턴 계산
|
||||
final progress = _controller.value * segmentCount;
|
||||
final distance = (index - progress).abs();
|
||||
final isLit = distance < 2 || (segmentCount - distance) < 2;
|
||||
final opacity = isLit ? 1.0 : 0.2;
|
||||
|
||||
return Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.gold.withValues(alpha: opacity),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
137
lib/src/app_theme.dart
Normal file
137
lib/src/app_theme.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 앱 테마 (Dark Fantasy 스타일)
|
||||
ThemeData buildAppTheme() => ThemeData(
|
||||
colorScheme: RetroColors.darkColorScheme,
|
||||
scaffoldBackgroundColor: RetroColors.deepBrown,
|
||||
useMaterial3: true,
|
||||
// 카드/다이얼로그 레트로 배경
|
||||
cardColor: RetroColors.darkBrown,
|
||||
dialogTheme: const DialogThemeData(
|
||||
backgroundColor: Color(0xFF24283B),
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 15,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
),
|
||||
// 앱바 레트로 스타일
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Color(0xFF24283B),
|
||||
foregroundColor: Color(0xFFC0CAF5),
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 15,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
),
|
||||
// 버튼 테마 (inherit: false로 애니메이션 lerp 오류 방지)
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF3D4260),
|
||||
foregroundColor: const Color(0xFFC0CAF5),
|
||||
textStyle: const TextStyle(
|
||||
inherit: false,
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFE0AF68),
|
||||
side: const BorderSide(color: Color(0xFFE0AF68), width: 2),
|
||||
textStyle: const TextStyle(
|
||||
inherit: false,
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFC0CAF5),
|
||||
textStyle: const TextStyle(
|
||||
inherit: false,
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 텍스트 테마
|
||||
textTheme: const TextTheme(
|
||||
headlineLarge: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 20,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
headlineMedium: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 16,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
headlineSmall: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 15,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
titleLarge: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 15,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
titleSmall: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
bodyLarge: TextStyle(fontSize: 18, color: Color(0xFFC0CAF5)),
|
||||
bodyMedium: TextStyle(fontSize: 17, color: Color(0xFFC0CAF5)),
|
||||
bodySmall: TextStyle(fontSize: 15, color: Color(0xFFC0CAF5)),
|
||||
labelLarge: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
labelMedium: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
labelSmall: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
),
|
||||
// 칩 테마
|
||||
chipTheme: const ChipThemeData(
|
||||
backgroundColor: Color(0xFF2A2E3F),
|
||||
labelStyle: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
side: BorderSide(color: Color(0xFF545C7E)),
|
||||
),
|
||||
// 리스트 타일 테마
|
||||
listTileTheme: const ListTileThemeData(
|
||||
textColor: Color(0xFFC0CAF5),
|
||||
iconColor: Color(0xFFE0AF68),
|
||||
),
|
||||
// 프로그레스 인디케이터
|
||||
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
||||
color: Color(0xFFE0AF68),
|
||||
linearTrackColor: Color(0xFF3B4261),
|
||||
),
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/core/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
|
||||
@@ -71,15 +71,14 @@ class AdService {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 프로덕션 광고 ID (AdMob 콘솔에서 생성 후 교체)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// TODO: AdMob 콘솔에서 광고 단위 생성 후 아래 ID 교체
|
||||
static const String _prodRewardedAndroid =
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 리워드 광고
|
||||
'ca-app-pub-6691216385521068/3457464395'; // Android 리워드 광고
|
||||
static const String _prodRewardedIos =
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 리워드 광고
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // TODO: iOS 리워드 광고 ID 교체
|
||||
static const String _prodInterstitialAndroid =
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 인터스티셜 광고
|
||||
'ca-app-pub-6691216385521068/1625507977'; // Android 인터스티셜 광고
|
||||
static const String _prodInterstitialIos =
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 인터스티셜 광고
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // TODO: iOS 인터스티셜 광고 ID 교체
|
||||
|
||||
/// 리워드 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID)
|
||||
String get _rewardAdUnitId {
|
||||
|
||||
497
lib/src/core/engine/arena_combat_simulator.dart
Normal file
497
lib/src/core/engine/arena_combat_simulator.dart
Normal file
@@ -0,0 +1,497 @@
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// 아레나 전투 시뮬레이터
|
||||
///
|
||||
/// ArenaService에서 분리된 전투 시뮬레이션 로직.
|
||||
/// 스킬 시스템을 포함한 턴 기반 전투를 처리한다.
|
||||
class ArenaCombatSimulator {
|
||||
ArenaCombatSimulator({required DeterministicRandom rng})
|
||||
: _rng = rng,
|
||||
_skillService = SkillService(rng: rng);
|
||||
|
||||
final DeterministicRandom _rng;
|
||||
final SkillService _skillService;
|
||||
|
||||
/// 전투 시뮬레이션 (애니메이션용 스트림)
|
||||
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) async* {
|
||||
final challengerStats = match.challenger.finalStats;
|
||||
final opponentStats = match.opponent.finalStats;
|
||||
|
||||
if (challengerStats == null || opponentStats == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final calculator = CombatCalculator(rng: _rng);
|
||||
|
||||
// 스킬 ID 목록 로드
|
||||
var challengerSkillIds = _getSkillIdsFromEntry(match.challenger);
|
||||
var opponentSkillIds = _getSkillIdsFromEntry(match.opponent);
|
||||
if (challengerSkillIds.isEmpty) {
|
||||
challengerSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
if (opponentSkillIds.isEmpty) {
|
||||
opponentSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
|
||||
// 스킬 시스템 상태 초기화
|
||||
var challengerSkillSystem = SkillSystemState.empty();
|
||||
var opponentSkillSystem = SkillSystemState.empty();
|
||||
|
||||
// DOT 및 디버프 추적
|
||||
var challengerDoTs = <DotEffect>[];
|
||||
var opponentDoTs = <DotEffect>[];
|
||||
var challengerDebuffs = <ActiveBuff>[];
|
||||
var opponentDebuffs = <ActiveBuff>[];
|
||||
|
||||
var playerCombatStats = challengerStats.copyWith(
|
||||
hpCurrent: challengerStats.hpMax,
|
||||
mpCurrent: challengerStats.mpMax,
|
||||
);
|
||||
|
||||
var opponentCombatStats = opponentStats.copyWith(
|
||||
hpCurrent: opponentStats.hpMax,
|
||||
mpCurrent: opponentStats.mpMax,
|
||||
);
|
||||
|
||||
int playerAccum = 0;
|
||||
int opponentAccum = 0;
|
||||
int elapsedMs = 0;
|
||||
const tickMs = 200;
|
||||
int turns = 0;
|
||||
|
||||
// 초기 상태 전송
|
||||
yield ArenaCombatTurn(
|
||||
challengerHp: playerCombatStats.hpCurrent,
|
||||
opponentHp: opponentCombatStats.hpCurrent,
|
||||
challengerHpMax: playerCombatStats.hpMax,
|
||||
opponentHpMax: opponentCombatStats.hpMax,
|
||||
challengerMp: playerCombatStats.mpCurrent,
|
||||
opponentMp: opponentCombatStats.mpCurrent,
|
||||
challengerMpMax: playerCombatStats.mpMax,
|
||||
opponentMpMax: opponentCombatStats.mpMax,
|
||||
);
|
||||
|
||||
while (playerCombatStats.isAlive && opponentCombatStats.hpCurrent > 0) {
|
||||
playerAccum += tickMs;
|
||||
opponentAccum += tickMs;
|
||||
elapsedMs += tickMs;
|
||||
|
||||
// 스킬 시스템 시간 업데이트
|
||||
challengerSkillSystem = challengerSkillSystem.copyWith(
|
||||
elapsedMs: elapsedMs,
|
||||
);
|
||||
opponentSkillSystem = opponentSkillSystem.copyWith(elapsedMs: elapsedMs);
|
||||
|
||||
int? challengerDamage;
|
||||
int? opponentDamage;
|
||||
bool isChallengerCritical = false;
|
||||
bool isOpponentCritical = false;
|
||||
bool isChallengerEvaded = false;
|
||||
bool isOpponentEvaded = false;
|
||||
bool isChallengerBlocked = false;
|
||||
bool isOpponentBlocked = false;
|
||||
String? challengerSkillUsed;
|
||||
String? opponentSkillUsed;
|
||||
int? challengerHealAmount;
|
||||
int? opponentHealAmount;
|
||||
|
||||
// DOT 틱 처리
|
||||
final dotResult = _processDotTicks(
|
||||
challengerDoTs: challengerDoTs,
|
||||
opponentDoTs: opponentDoTs,
|
||||
playerStats: playerCombatStats,
|
||||
opponentStats: opponentCombatStats,
|
||||
tickMs: tickMs,
|
||||
);
|
||||
challengerDoTs = dotResult.challengerDoTs;
|
||||
opponentDoTs = dotResult.opponentDoTs;
|
||||
playerCombatStats = dotResult.playerStats;
|
||||
opponentCombatStats = dotResult.opponentStats;
|
||||
|
||||
// 만료된 디버프 정리
|
||||
challengerDebuffs = challengerDebuffs
|
||||
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
||||
.toList();
|
||||
opponentDebuffs = opponentDebuffs
|
||||
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
||||
.toList();
|
||||
|
||||
// 도전자 턴
|
||||
if (playerAccum >= playerCombatStats.attackDelayMs) {
|
||||
playerAccum = 0;
|
||||
|
||||
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
opponentCombatStats,
|
||||
match.opponent.characterName,
|
||||
);
|
||||
|
||||
final turnResult = _processCharacterTurn(
|
||||
player: playerCombatStats,
|
||||
target: opponentCombatStats,
|
||||
targetMonster: opponentMonsterStats,
|
||||
targetName: match.opponent.characterName,
|
||||
entry: match.challenger,
|
||||
skillIds: challengerSkillIds,
|
||||
skillSystem: challengerSkillSystem,
|
||||
activeDoTs: challengerDoTs,
|
||||
activeDebuffs: opponentDebuffs,
|
||||
calculator: calculator,
|
||||
elapsedMs: elapsedMs,
|
||||
);
|
||||
|
||||
playerCombatStats = turnResult.player;
|
||||
opponentCombatStats = turnResult.target;
|
||||
challengerSkillSystem = turnResult.skillSystem;
|
||||
challengerDoTs = turnResult.activeDoTs;
|
||||
opponentDebuffs = turnResult.targetDebuffs;
|
||||
challengerDamage = turnResult.damage;
|
||||
isChallengerCritical = turnResult.isCritical;
|
||||
isOpponentEvaded = turnResult.isTargetEvaded;
|
||||
challengerSkillUsed = turnResult.skillUsed;
|
||||
challengerHealAmount = turnResult.healAmount;
|
||||
}
|
||||
|
||||
// 상대 턴
|
||||
if (opponentCombatStats.hpCurrent > 0 &&
|
||||
opponentAccum >= opponentCombatStats.attackDelayMs) {
|
||||
opponentAccum = 0;
|
||||
|
||||
var challengerMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
playerCombatStats,
|
||||
match.challenger.characterName,
|
||||
);
|
||||
|
||||
final turnResult = _processCharacterTurn(
|
||||
player: opponentCombatStats,
|
||||
target: playerCombatStats,
|
||||
targetMonster: challengerMonsterStats,
|
||||
targetName: match.challenger.characterName,
|
||||
entry: match.opponent,
|
||||
skillIds: opponentSkillIds,
|
||||
skillSystem: opponentSkillSystem,
|
||||
activeDoTs: opponentDoTs,
|
||||
activeDebuffs: challengerDebuffs,
|
||||
calculator: calculator,
|
||||
elapsedMs: elapsedMs,
|
||||
);
|
||||
|
||||
opponentCombatStats = turnResult.player;
|
||||
playerCombatStats = turnResult.target;
|
||||
opponentSkillSystem = turnResult.skillSystem;
|
||||
opponentDoTs = turnResult.activeDoTs;
|
||||
challengerDebuffs = turnResult.targetDebuffs;
|
||||
opponentDamage = turnResult.damage;
|
||||
isOpponentCritical = turnResult.isCritical;
|
||||
isChallengerEvaded = turnResult.isTargetEvaded;
|
||||
isChallengerBlocked = turnResult.isTargetBlocked;
|
||||
opponentSkillUsed = turnResult.skillUsed;
|
||||
opponentHealAmount = turnResult.healAmount;
|
||||
}
|
||||
|
||||
// 액션이 발생했을 때만 턴 전송
|
||||
final hasAction =
|
||||
challengerDamage != null ||
|
||||
opponentDamage != null ||
|
||||
challengerHealAmount != null ||
|
||||
opponentHealAmount != null ||
|
||||
challengerSkillUsed != null ||
|
||||
opponentSkillUsed != null;
|
||||
|
||||
if (hasAction) {
|
||||
turns++;
|
||||
yield ArenaCombatTurn(
|
||||
challengerDamage: challengerDamage,
|
||||
opponentDamage: opponentDamage,
|
||||
challengerHp: playerCombatStats.hpCurrent,
|
||||
opponentHp: opponentCombatStats.hpCurrent,
|
||||
challengerHpMax: playerCombatStats.hpMax,
|
||||
opponentHpMax: opponentCombatStats.hpMax,
|
||||
challengerMp: playerCombatStats.mpCurrent,
|
||||
opponentMp: opponentCombatStats.mpCurrent,
|
||||
challengerMpMax: playerCombatStats.mpMax,
|
||||
opponentMpMax: opponentCombatStats.mpMax,
|
||||
isChallengerCritical: isChallengerCritical,
|
||||
isOpponentCritical: isOpponentCritical,
|
||||
isChallengerEvaded: isChallengerEvaded,
|
||||
isOpponentEvaded: isOpponentEvaded,
|
||||
isChallengerBlocked: isChallengerBlocked,
|
||||
isOpponentBlocked: isOpponentBlocked,
|
||||
challengerSkillUsed: challengerSkillUsed,
|
||||
opponentSkillUsed: opponentSkillUsed,
|
||||
challengerHealAmount: challengerHealAmount,
|
||||
opponentHealAmount: opponentHealAmount,
|
||||
);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
if (turns > 1000) break;
|
||||
}
|
||||
}
|
||||
|
||||
/// DOT 틱 처리 (양측)
|
||||
({
|
||||
List<DotEffect> challengerDoTs,
|
||||
List<DotEffect> opponentDoTs,
|
||||
CombatStats playerStats,
|
||||
CombatStats opponentStats,
|
||||
})
|
||||
_processDotTicks({
|
||||
required List<DotEffect> challengerDoTs,
|
||||
required List<DotEffect> opponentDoTs,
|
||||
required CombatStats playerStats,
|
||||
required CombatStats opponentStats,
|
||||
required int tickMs,
|
||||
}) {
|
||||
var updatedPlayerStats = playerStats;
|
||||
var updatedOpponentStats = opponentStats;
|
||||
|
||||
// 도전자 -> 상대에게 적용된 DOT
|
||||
var dotDamageToOpponent = 0;
|
||||
final updatedChallengerDoTs = <DotEffect>[];
|
||||
for (final dot in challengerDoTs) {
|
||||
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
||||
if (ticksTriggered > 0) {
|
||||
dotDamageToOpponent += dot.damagePerTick * ticksTriggered;
|
||||
}
|
||||
if (updatedDot.isActive) updatedChallengerDoTs.add(updatedDot);
|
||||
}
|
||||
if (dotDamageToOpponent > 0 && updatedOpponentStats.hpCurrent > 0) {
|
||||
updatedOpponentStats = updatedOpponentStats.copyWith(
|
||||
hpCurrent: (updatedOpponentStats.hpCurrent - dotDamageToOpponent).clamp(
|
||||
0,
|
||||
updatedOpponentStats.hpMax,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 상대 -> 도전자에게 적용된 DOT
|
||||
var dotDamageToChallenger = 0;
|
||||
final updatedOpponentDoTs = <DotEffect>[];
|
||||
for (final dot in opponentDoTs) {
|
||||
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
||||
if (ticksTriggered > 0) {
|
||||
dotDamageToChallenger += dot.damagePerTick * ticksTriggered;
|
||||
}
|
||||
if (updatedDot.isActive) updatedOpponentDoTs.add(updatedDot);
|
||||
}
|
||||
if (dotDamageToChallenger > 0 && updatedPlayerStats.isAlive) {
|
||||
updatedPlayerStats = updatedPlayerStats.copyWith(
|
||||
hpCurrent: (updatedPlayerStats.hpCurrent - dotDamageToChallenger).clamp(
|
||||
0,
|
||||
updatedPlayerStats.hpMax,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
challengerDoTs: updatedChallengerDoTs,
|
||||
opponentDoTs: updatedOpponentDoTs,
|
||||
playerStats: updatedPlayerStats,
|
||||
opponentStats: updatedOpponentStats,
|
||||
);
|
||||
}
|
||||
|
||||
/// 캐릭터 턴 처리 (도전자/상대 공통)
|
||||
({
|
||||
CombatStats player,
|
||||
CombatStats target,
|
||||
SkillSystemState skillSystem,
|
||||
List<DotEffect> activeDoTs,
|
||||
List<ActiveBuff> targetDebuffs,
|
||||
int? damage,
|
||||
bool isCritical,
|
||||
bool isTargetEvaded,
|
||||
bool isTargetBlocked,
|
||||
String? skillUsed,
|
||||
int? healAmount,
|
||||
})
|
||||
_processCharacterTurn({
|
||||
required CombatStats player,
|
||||
required CombatStats target,
|
||||
required MonsterCombatStats targetMonster,
|
||||
required String targetName,
|
||||
required HallOfFameEntry entry,
|
||||
required List<String> skillIds,
|
||||
required SkillSystemState skillSystem,
|
||||
required List<DotEffect> activeDoTs,
|
||||
required List<ActiveBuff> activeDebuffs,
|
||||
required CombatCalculator calculator,
|
||||
required int elapsedMs,
|
||||
}) {
|
||||
int? damage;
|
||||
bool isCritical = false;
|
||||
bool isTargetEvaded = false;
|
||||
bool isTargetBlocked = false;
|
||||
String? skillUsed;
|
||||
int? healAmount;
|
||||
var updatedPlayer = player;
|
||||
var updatedTarget = target;
|
||||
var updatedSkillSystem = skillSystem;
|
||||
var updatedDoTs = [...activeDoTs];
|
||||
var updatedDebuffs = [...activeDebuffs];
|
||||
|
||||
final selectedSkill = _skillService.selectAutoSkill(
|
||||
player: updatedPlayer,
|
||||
monster: targetMonster,
|
||||
skillSystem: updatedSkillSystem,
|
||||
availableSkillIds: skillIds,
|
||||
activeDoTs: updatedDoTs,
|
||||
activeDebuffs: updatedDebuffs,
|
||||
);
|
||||
|
||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||
final skillRank = _getSkillRankFromEntry(entry, selectedSkill.id);
|
||||
final skillResult = _skillService.useAttackSkillWithRank(
|
||||
skill: selectedSkill,
|
||||
player: updatedPlayer,
|
||||
monster: targetMonster,
|
||||
skillSystem: updatedSkillSystem,
|
||||
rank: skillRank,
|
||||
);
|
||||
updatedPlayer = skillResult.updatedPlayer;
|
||||
updatedTarget = updatedTarget.copyWith(
|
||||
hpCurrent: skillResult.updatedMonster.hpCurrent,
|
||||
);
|
||||
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||
skillUsed = selectedSkill.name;
|
||||
damage = skillResult.result.damage;
|
||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||
final skillResult = _skillService.useDotSkill(
|
||||
skill: selectedSkill,
|
||||
player: updatedPlayer,
|
||||
skillSystem: updatedSkillSystem,
|
||||
playerInt: updatedPlayer.atk ~/ 10,
|
||||
playerWis: updatedPlayer.def ~/ 10,
|
||||
);
|
||||
updatedPlayer = skillResult.updatedPlayer;
|
||||
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||
if (skillResult.dotEffect != null) {
|
||||
updatedDoTs.add(skillResult.dotEffect!);
|
||||
}
|
||||
skillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||
final skillResult = _skillService.useHealSkill(
|
||||
skill: selectedSkill,
|
||||
player: updatedPlayer,
|
||||
skillSystem: updatedSkillSystem,
|
||||
);
|
||||
updatedPlayer = skillResult.updatedPlayer;
|
||||
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||
skillUsed = selectedSkill.name;
|
||||
healAmount = skillResult.result.healedAmount;
|
||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||
final skillResult = _skillService.useBuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: updatedPlayer,
|
||||
skillSystem: updatedSkillSystem,
|
||||
);
|
||||
updatedPlayer = skillResult.updatedPlayer;
|
||||
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||
skillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||
final skillResult = _skillService.useDebuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: updatedPlayer,
|
||||
skillSystem: updatedSkillSystem,
|
||||
currentDebuffs: updatedDebuffs,
|
||||
);
|
||||
updatedPlayer = skillResult.updatedPlayer;
|
||||
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||
final debuffEffect = skillResult.debuffEffect;
|
||||
if (debuffEffect != null) {
|
||||
updatedDebuffs =
|
||||
updatedDebuffs
|
||||
.where((ActiveBuff d) => d.effect.id != debuffEffect.effect.id)
|
||||
.toList()
|
||||
..add(debuffEffect);
|
||||
}
|
||||
skillUsed = selectedSkill.name;
|
||||
} else {
|
||||
// 일반 공격
|
||||
final opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
updatedTarget,
|
||||
targetName,
|
||||
);
|
||||
final result = calculator.playerAttackMonster(
|
||||
attacker: updatedPlayer,
|
||||
defender: opponentMonsterStats,
|
||||
);
|
||||
updatedTarget = updatedTarget.copyWith(
|
||||
hpCurrent: result.updatedDefender.hpCurrent,
|
||||
);
|
||||
|
||||
if (result.result.isHit) {
|
||||
damage = result.result.damage;
|
||||
isCritical = result.result.isCritical;
|
||||
} else {
|
||||
isTargetEvaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
player: updatedPlayer,
|
||||
target: updatedTarget,
|
||||
skillSystem: updatedSkillSystem,
|
||||
activeDoTs: updatedDoTs,
|
||||
targetDebuffs: updatedDebuffs,
|
||||
damage: damage,
|
||||
isCritical: isCritical,
|
||||
isTargetEvaded: isTargetEvaded,
|
||||
isTargetBlocked: isTargetBlocked,
|
||||
skillUsed: skillUsed,
|
||||
healAmount: healAmount,
|
||||
);
|
||||
}
|
||||
|
||||
/// 스킬 ID 목록 추출 (HallOfFameEntry에서)
|
||||
List<String> _getSkillIdsFromEntry(HallOfFameEntry entry) {
|
||||
final skillData = entry.finalSkills;
|
||||
if (skillData == null || skillData.isEmpty) return [];
|
||||
|
||||
final skillIds = <String>[];
|
||||
for (final data in skillData) {
|
||||
final skillName = data['name'];
|
||||
if (skillName != null) {
|
||||
final skill = SkillData.getSkillBySpellName(skillName);
|
||||
if (skill != null) {
|
||||
skillIds.add(skill.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return skillIds;
|
||||
}
|
||||
|
||||
/// 스킬 랭크 조회 (HallOfFameEntry의 finalSkills에서)
|
||||
int _getSkillRankFromEntry(HallOfFameEntry entry, String skillId) {
|
||||
final skill = SkillData.getSkillById(skillId);
|
||||
if (skill == null) return 1;
|
||||
|
||||
final skillData = entry.finalSkills;
|
||||
if (skillData == null || skillData.isEmpty) return 1;
|
||||
|
||||
for (final data in skillData) {
|
||||
if (data['name'] == skill.name) {
|
||||
final rankStr = data['rank'] ?? 'I';
|
||||
return switch (rankStr) {
|
||||
'I' => 1,
|
||||
'II' => 2,
|
||||
'III' => 3,
|
||||
'IV' => 4,
|
||||
'V' => 5,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/arena_combat_simulator.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.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/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// 아레나 서비스
|
||||
@@ -23,64 +20,6 @@ class ArenaService {
|
||||
|
||||
final DeterministicRandom _rng;
|
||||
|
||||
late final SkillService _skillService = SkillService(rng: _rng);
|
||||
|
||||
// ============================================================================
|
||||
// 스킬 시스템 헬퍼
|
||||
// ============================================================================
|
||||
|
||||
/// HallOfFameEntry의 finalSkills에서 Skill 목록 추출
|
||||
List<Skill> _getSkillsFromEntry(HallOfFameEntry entry) {
|
||||
final skillData = entry.finalSkills;
|
||||
if (skillData == null || skillData.isEmpty) return [];
|
||||
|
||||
final skills = <Skill>[];
|
||||
for (final data in skillData) {
|
||||
final skillName = data['name'];
|
||||
if (skillName != null) {
|
||||
final skill = SkillData.getSkillBySpellName(skillName);
|
||||
if (skill != null) {
|
||||
skills.add(skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
/// 스킬 ID 목록 추출 (HallOfFameEntry에서)
|
||||
List<String> _getSkillIdsFromEntry(HallOfFameEntry entry) {
|
||||
return _getSkillsFromEntry(entry).map((s) => s.id).toList();
|
||||
}
|
||||
|
||||
/// 스킬 랭크 조회 (HallOfFameEntry의 finalSkills에서)
|
||||
int _getSkillRankFromEntry(HallOfFameEntry entry, String skillId) {
|
||||
final skill = SkillData.getSkillById(skillId);
|
||||
if (skill == null) return 1;
|
||||
|
||||
final skillData = entry.finalSkills;
|
||||
if (skillData == null || skillData.isEmpty) return 1;
|
||||
|
||||
for (final data in skillData) {
|
||||
if (data['name'] == skill.name) {
|
||||
final rankStr = data['rank'] ?? 'I';
|
||||
return _romanToInt(rankStr);
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// 로마 숫자 → 정수 변환
|
||||
int _romanToInt(String roman) {
|
||||
return switch (roman) {
|
||||
'I' => 1,
|
||||
'II' => 2,
|
||||
'III' => 3,
|
||||
'IV' => 4,
|
||||
'V' => 5,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 상대 결정
|
||||
// ============================================================================
|
||||
@@ -230,452 +169,10 @@ class ArenaService {
|
||||
|
||||
/// 전투 시뮬레이션 (애니메이션용 스트림)
|
||||
///
|
||||
/// progress_service._processCombatTickWithSkills()와 동일한 로직 사용
|
||||
/// [match] 대전 정보
|
||||
/// Returns: 턴별 전투 상황 스트림
|
||||
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) async* {
|
||||
final calculator = CombatCalculator(rng: _rng);
|
||||
|
||||
final challengerStats = match.challenger.finalStats;
|
||||
final opponentStats = match.opponent.finalStats;
|
||||
|
||||
if (challengerStats == null || opponentStats == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 스킬 ID 목록 로드 (SkillBook과 동일한 방식)
|
||||
var challengerSkillIds = _getSkillIdsFromEntry(match.challenger);
|
||||
var opponentSkillIds = _getSkillIdsFromEntry(match.opponent);
|
||||
|
||||
// 스킬이 없으면 기본 스킬 사용
|
||||
if (challengerSkillIds.isEmpty) {
|
||||
challengerSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
if (opponentSkillIds.isEmpty) {
|
||||
opponentSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
|
||||
// 스킬 시스템 상태 초기화
|
||||
var challengerSkillSystem = SkillSystemState.empty();
|
||||
var opponentSkillSystem = SkillSystemState.empty();
|
||||
|
||||
// DOT 및 디버프 추적 (일반 전투와 동일)
|
||||
var challengerDoTs = <DotEffect>[];
|
||||
var opponentDoTs = <DotEffect>[];
|
||||
var challengerDebuffs = <ActiveBuff>[];
|
||||
var opponentDebuffs = <ActiveBuff>[];
|
||||
|
||||
var playerCombatStats = challengerStats.copyWith(
|
||||
hpCurrent: challengerStats.hpMax,
|
||||
mpCurrent: challengerStats.mpMax,
|
||||
);
|
||||
|
||||
var opponentCombatStats = opponentStats.copyWith(
|
||||
hpCurrent: opponentStats.hpMax,
|
||||
mpCurrent: opponentStats.mpMax,
|
||||
);
|
||||
|
||||
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
opponentCombatStats,
|
||||
match.opponent.characterName,
|
||||
);
|
||||
|
||||
var challengerMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
playerCombatStats,
|
||||
match.challenger.characterName,
|
||||
);
|
||||
|
||||
int playerAccum = 0;
|
||||
int opponentAccum = 0;
|
||||
int elapsedMs = 0;
|
||||
const tickMs = 200;
|
||||
int turns = 0;
|
||||
|
||||
// 초기 상태 전송
|
||||
yield ArenaCombatTurn(
|
||||
challengerHp: playerCombatStats.hpCurrent,
|
||||
opponentHp: opponentCombatStats.hpCurrent,
|
||||
challengerHpMax: playerCombatStats.hpMax,
|
||||
opponentHpMax: opponentCombatStats.hpMax,
|
||||
challengerMp: playerCombatStats.mpCurrent,
|
||||
opponentMp: opponentCombatStats.mpCurrent,
|
||||
challengerMpMax: playerCombatStats.mpMax,
|
||||
opponentMpMax: opponentCombatStats.mpMax,
|
||||
);
|
||||
|
||||
while (playerCombatStats.isAlive && opponentCombatStats.hpCurrent > 0) {
|
||||
playerAccum += tickMs;
|
||||
opponentAccum += tickMs;
|
||||
elapsedMs += tickMs;
|
||||
|
||||
// 스킬 시스템 시간 업데이트
|
||||
challengerSkillSystem = challengerSkillSystem.copyWith(
|
||||
elapsedMs: elapsedMs,
|
||||
);
|
||||
opponentSkillSystem = opponentSkillSystem.copyWith(elapsedMs: elapsedMs);
|
||||
|
||||
int? challengerDamage;
|
||||
int? opponentDamage;
|
||||
bool isChallengerCritical = false;
|
||||
bool isOpponentCritical = false;
|
||||
bool isChallengerEvaded = false;
|
||||
bool isOpponentEvaded = false;
|
||||
bool isChallengerBlocked = false;
|
||||
bool isOpponentBlocked = false;
|
||||
String? challengerSkillUsed;
|
||||
String? opponentSkillUsed;
|
||||
int? challengerHealAmount;
|
||||
int? opponentHealAmount;
|
||||
|
||||
// =========================================================================
|
||||
// DOT 틱 처리 (도전자 → 상대에게 적용된 DOT)
|
||||
// =========================================================================
|
||||
var dotDamageToOpponent = 0;
|
||||
final updatedChallengerDoTs = <DotEffect>[];
|
||||
for (final dot in challengerDoTs) {
|
||||
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
||||
if (ticksTriggered > 0) {
|
||||
dotDamageToOpponent += dot.damagePerTick * ticksTriggered;
|
||||
}
|
||||
if (updatedDot.isActive) {
|
||||
updatedChallengerDoTs.add(updatedDot);
|
||||
}
|
||||
}
|
||||
challengerDoTs = updatedChallengerDoTs;
|
||||
|
||||
if (dotDamageToOpponent > 0 && opponentCombatStats.hpCurrent > 0) {
|
||||
opponentCombatStats = opponentCombatStats.copyWith(
|
||||
hpCurrent: (opponentCombatStats.hpCurrent - dotDamageToOpponent)
|
||||
.clamp(0, opponentCombatStats.hpMax),
|
||||
);
|
||||
}
|
||||
|
||||
// DOT 틱 처리 (상대 → 도전자에게 적용된 DOT)
|
||||
var dotDamageToChallenger = 0;
|
||||
final updatedOpponentDoTs = <DotEffect>[];
|
||||
for (final dot in opponentDoTs) {
|
||||
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
||||
if (ticksTriggered > 0) {
|
||||
dotDamageToChallenger += dot.damagePerTick * ticksTriggered;
|
||||
}
|
||||
if (updatedDot.isActive) {
|
||||
updatedOpponentDoTs.add(updatedDot);
|
||||
}
|
||||
}
|
||||
opponentDoTs = updatedOpponentDoTs;
|
||||
|
||||
if (dotDamageToChallenger > 0 && playerCombatStats.isAlive) {
|
||||
playerCombatStats = playerCombatStats.copyWith(
|
||||
hpCurrent: (playerCombatStats.hpCurrent - dotDamageToChallenger)
|
||||
.clamp(0, playerCombatStats.hpMax),
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 만료된 디버프 정리
|
||||
// =========================================================================
|
||||
challengerDebuffs = challengerDebuffs
|
||||
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
||||
.toList();
|
||||
opponentDebuffs = opponentDebuffs
|
||||
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
||||
.toList();
|
||||
|
||||
// =========================================================================
|
||||
// 도전자 턴 (selectAutoSkill 사용 - 일반 전투와 동일)
|
||||
// =========================================================================
|
||||
if (playerAccum >= playerCombatStats.attackDelayMs) {
|
||||
playerAccum = 0;
|
||||
|
||||
// 상대 몬스터 스탯 동기화
|
||||
opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
opponentCombatStats,
|
||||
match.opponent.characterName,
|
||||
);
|
||||
|
||||
// 스킬 자동 선택 (progress_service와 동일한 로직)
|
||||
final selectedSkill = _skillService.selectAutoSkill(
|
||||
player: playerCombatStats,
|
||||
monster: opponentMonsterStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
availableSkillIds: challengerSkillIds,
|
||||
activeDoTs: challengerDoTs,
|
||||
activeDebuffs: opponentDebuffs,
|
||||
);
|
||||
|
||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||
// 스킬 랭크 조회 및 적용
|
||||
final skillRank = _getSkillRankFromEntry(
|
||||
match.challenger,
|
||||
selectedSkill.id,
|
||||
);
|
||||
final skillResult = _skillService.useAttackSkillWithRank(
|
||||
skill: selectedSkill,
|
||||
player: playerCombatStats,
|
||||
monster: opponentMonsterStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
rank: skillRank,
|
||||
);
|
||||
playerCombatStats = skillResult.updatedPlayer;
|
||||
opponentCombatStats = opponentCombatStats.copyWith(
|
||||
hpCurrent: skillResult.updatedMonster.hpCurrent,
|
||||
);
|
||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||
challengerSkillUsed = selectedSkill.name;
|
||||
challengerDamage = skillResult.result.damage;
|
||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||
// DOT 스킬 사용
|
||||
final skillResult = _skillService.useDotSkill(
|
||||
skill: selectedSkill,
|
||||
player: playerCombatStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
playerInt: playerCombatStats.atk ~/ 10,
|
||||
playerWis: playerCombatStats.def ~/ 10,
|
||||
);
|
||||
playerCombatStats = skillResult.updatedPlayer;
|
||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||
if (skillResult.dotEffect != null) {
|
||||
challengerDoTs.add(skillResult.dotEffect!);
|
||||
}
|
||||
challengerSkillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||
// 회복 스킬 사용
|
||||
final skillResult = _skillService.useHealSkill(
|
||||
skill: selectedSkill,
|
||||
player: playerCombatStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
);
|
||||
playerCombatStats = skillResult.updatedPlayer;
|
||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||
challengerSkillUsed = selectedSkill.name;
|
||||
challengerHealAmount = skillResult.result.healedAmount;
|
||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||
// 버프 스킬 사용
|
||||
final skillResult = _skillService.useBuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: playerCombatStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
);
|
||||
playerCombatStats = skillResult.updatedPlayer;
|
||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||
challengerSkillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||
// 디버프 스킬 사용
|
||||
final skillResult = _skillService.useDebuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: playerCombatStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
currentDebuffs: opponentDebuffs,
|
||||
);
|
||||
playerCombatStats = skillResult.updatedPlayer;
|
||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||
final debuffEffect = skillResult.debuffEffect;
|
||||
if (debuffEffect != null) {
|
||||
opponentDebuffs =
|
||||
opponentDebuffs
|
||||
.where(
|
||||
(ActiveBuff d) => d.effect.id != debuffEffect.effect.id,
|
||||
)
|
||||
.toList()
|
||||
..add(debuffEffect);
|
||||
}
|
||||
challengerSkillUsed = selectedSkill.name;
|
||||
} else {
|
||||
// 일반 공격
|
||||
final result = calculator.playerAttackMonster(
|
||||
attacker: playerCombatStats,
|
||||
defender: opponentMonsterStats,
|
||||
);
|
||||
opponentCombatStats = opponentCombatStats.copyWith(
|
||||
hpCurrent: result.updatedDefender.hpCurrent,
|
||||
);
|
||||
|
||||
if (result.result.isHit) {
|
||||
challengerDamage = result.result.damage;
|
||||
isChallengerCritical = result.result.isCritical;
|
||||
} else {
|
||||
isOpponentEvaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 상대 턴 (selectAutoSkill 사용 - 일반 전투와 동일)
|
||||
// =========================================================================
|
||||
if (opponentCombatStats.hpCurrent > 0 &&
|
||||
opponentAccum >= opponentCombatStats.attackDelayMs) {
|
||||
opponentAccum = 0;
|
||||
|
||||
// 도전자 몬스터 스탯 동기화
|
||||
challengerMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
playerCombatStats,
|
||||
match.challenger.characterName,
|
||||
);
|
||||
|
||||
// 스킬 자동 선택 (progress_service와 동일한 로직)
|
||||
final selectedSkill = _skillService.selectAutoSkill(
|
||||
player: opponentCombatStats,
|
||||
monster: challengerMonsterStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
availableSkillIds: opponentSkillIds,
|
||||
activeDoTs: opponentDoTs,
|
||||
activeDebuffs: challengerDebuffs,
|
||||
);
|
||||
|
||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||
// 스킬 랭크 조회 및 적용
|
||||
final skillRank = _getSkillRankFromEntry(
|
||||
match.opponent,
|
||||
selectedSkill.id,
|
||||
);
|
||||
final skillResult = _skillService.useAttackSkillWithRank(
|
||||
skill: selectedSkill,
|
||||
player: opponentCombatStats,
|
||||
monster: challengerMonsterStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
rank: skillRank,
|
||||
);
|
||||
opponentCombatStats = skillResult.updatedPlayer;
|
||||
playerCombatStats = playerCombatStats.copyWith(
|
||||
hpCurrent: skillResult.updatedMonster.hpCurrent,
|
||||
);
|
||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||
opponentSkillUsed = selectedSkill.name;
|
||||
opponentDamage = skillResult.result.damage;
|
||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||
// DOT 스킬 사용
|
||||
final skillResult = _skillService.useDotSkill(
|
||||
skill: selectedSkill,
|
||||
player: opponentCombatStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
playerInt: opponentCombatStats.atk ~/ 10,
|
||||
playerWis: opponentCombatStats.def ~/ 10,
|
||||
);
|
||||
opponentCombatStats = skillResult.updatedPlayer;
|
||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||
if (skillResult.dotEffect != null) {
|
||||
opponentDoTs.add(skillResult.dotEffect!);
|
||||
}
|
||||
opponentSkillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||
// 회복 스킬 사용
|
||||
final skillResult = _skillService.useHealSkill(
|
||||
skill: selectedSkill,
|
||||
player: opponentCombatStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
);
|
||||
opponentCombatStats = skillResult.updatedPlayer;
|
||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||
opponentSkillUsed = selectedSkill.name;
|
||||
opponentHealAmount = skillResult.result.healedAmount;
|
||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||
// 버프 스킬 사용
|
||||
final skillResult = _skillService.useBuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: opponentCombatStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
);
|
||||
opponentCombatStats = skillResult.updatedPlayer;
|
||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||
opponentSkillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||
// 디버프 스킬 사용
|
||||
final skillResult = _skillService.useDebuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: opponentCombatStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
currentDebuffs: challengerDebuffs,
|
||||
);
|
||||
opponentCombatStats = skillResult.updatedPlayer;
|
||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||
final debuffEffect = skillResult.debuffEffect;
|
||||
if (debuffEffect != null) {
|
||||
challengerDebuffs =
|
||||
challengerDebuffs
|
||||
.where(
|
||||
(ActiveBuff d) => d.effect.id != debuffEffect.effect.id,
|
||||
)
|
||||
.toList()
|
||||
..add(debuffEffect);
|
||||
}
|
||||
opponentSkillUsed = selectedSkill.name;
|
||||
} else {
|
||||
// 일반 공격 (디버프 효과 적용)
|
||||
var debuffedOpponent = opponentCombatStats;
|
||||
if (challengerDebuffs.isNotEmpty) {
|
||||
double atkMod = 0;
|
||||
for (final debuff in challengerDebuffs) {
|
||||
if (!debuff.isExpired(elapsedMs)) {
|
||||
atkMod += debuff.effect.atkModifier;
|
||||
}
|
||||
}
|
||||
final newAtk = (opponentCombatStats.atk * (1 + atkMod))
|
||||
.round()
|
||||
.clamp(opponentCombatStats.atk ~/ 10, opponentCombatStats.atk);
|
||||
debuffedOpponent = opponentCombatStats.copyWith(atk: newAtk);
|
||||
}
|
||||
|
||||
opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
debuffedOpponent,
|
||||
match.opponent.characterName,
|
||||
);
|
||||
final result = calculator.monsterAttackPlayer(
|
||||
attacker: opponentMonsterStats,
|
||||
defender: playerCombatStats,
|
||||
);
|
||||
playerCombatStats = result.updatedDefender;
|
||||
|
||||
if (result.result.isHit) {
|
||||
opponentDamage = result.result.damage;
|
||||
isOpponentCritical = result.result.isCritical;
|
||||
isChallengerBlocked = result.result.isBlocked;
|
||||
} else {
|
||||
isChallengerEvaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 액션이 발생했을 때만 턴 전송
|
||||
final hasAction =
|
||||
challengerDamage != null ||
|
||||
opponentDamage != null ||
|
||||
challengerHealAmount != null ||
|
||||
opponentHealAmount != null ||
|
||||
challengerSkillUsed != null ||
|
||||
opponentSkillUsed != null;
|
||||
|
||||
if (hasAction) {
|
||||
turns++;
|
||||
yield ArenaCombatTurn(
|
||||
challengerDamage: challengerDamage,
|
||||
opponentDamage: opponentDamage,
|
||||
challengerHp: playerCombatStats.hpCurrent,
|
||||
opponentHp: opponentCombatStats.hpCurrent,
|
||||
challengerHpMax: playerCombatStats.hpMax,
|
||||
opponentHpMax: opponentCombatStats.hpMax,
|
||||
challengerMp: playerCombatStats.mpCurrent,
|
||||
opponentMp: opponentCombatStats.mpCurrent,
|
||||
challengerMpMax: playerCombatStats.mpMax,
|
||||
opponentMpMax: opponentCombatStats.mpMax,
|
||||
isChallengerCritical: isChallengerCritical,
|
||||
isOpponentCritical: isOpponentCritical,
|
||||
isChallengerEvaded: isChallengerEvaded,
|
||||
isOpponentEvaded: isOpponentEvaded,
|
||||
isChallengerBlocked: isChallengerBlocked,
|
||||
isOpponentBlocked: isOpponentBlocked,
|
||||
challengerSkillUsed: challengerSkillUsed,
|
||||
opponentSkillUsed: opponentSkillUsed,
|
||||
challengerHealAmount: challengerHealAmount,
|
||||
opponentHealAmount: opponentHealAmount,
|
||||
);
|
||||
|
||||
// 애니메이션을 위한 딜레이
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
// 무한 루프 방지
|
||||
if (turns > 1000) break;
|
||||
}
|
||||
/// ArenaCombatSimulator에 위임하여 턴별 전투 상황을 스트림으로 반환.
|
||||
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) {
|
||||
final simulator = ArenaCombatSimulator(rng: _rng);
|
||||
return simulator.simulateCombat(match);
|
||||
}
|
||||
// ============================================================================
|
||||
// AI 베팅 슬롯 선택
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:asciineverdie/data/class_data.dart';
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/model/class_traits.dart';
|
||||
import 'package:asciineverdie/src/core/engine/player_attack_processor.dart';
|
||||
import 'package:asciineverdie/src/core/engine/potion_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||
@@ -126,7 +127,8 @@ class CombatTickService {
|
||||
|
||||
// 플레이어 공격 체크
|
||||
if (playerAccumulator >= playerStats.attackDelayMs) {
|
||||
final attackResult = _processPlayerAttack(
|
||||
final attackProcessor = PlayerAttackProcessor(rng: rng);
|
||||
final attackResult = attackProcessor.processAttack(
|
||||
state: state,
|
||||
playerStats: playerStats,
|
||||
monsterStats: monsterStats,
|
||||
@@ -363,249 +365,6 @@ class CombatTickService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 플레이어 공격 처리
|
||||
({
|
||||
CombatStats playerStats,
|
||||
MonsterCombatStats monsterStats,
|
||||
SkillSystemState skillSystem,
|
||||
List<DotEffect> activeDoTs,
|
||||
List<ActiveBuff> activeDebuffs,
|
||||
int totalDamageDealt,
|
||||
List<CombatEvent> events,
|
||||
bool isFirstPlayerAttack,
|
||||
})
|
||||
_processPlayerAttack({
|
||||
required GameState state,
|
||||
required CombatStats playerStats,
|
||||
required MonsterCombatStats monsterStats,
|
||||
required SkillSystemState updatedSkillSystem,
|
||||
required List<DotEffect> activeDoTs,
|
||||
required List<ActiveBuff> activeDebuffs,
|
||||
required int totalDamageDealt,
|
||||
required int timestamp,
|
||||
required CombatCalculator calculator,
|
||||
required SkillService skillService,
|
||||
required bool isFirstPlayerAttack,
|
||||
required double firstStrikeBonus,
|
||||
required bool hasMultiAttack,
|
||||
double healingMultiplier = 1.0,
|
||||
}) {
|
||||
final events = <CombatEvent>[];
|
||||
var newPlayerStats = playerStats;
|
||||
var newMonsterStats = monsterStats;
|
||||
var newSkillSystem = updatedSkillSystem;
|
||||
var newActiveDoTs = [...activeDoTs];
|
||||
var newActiveBuffs = [...activeDebuffs];
|
||||
var newTotalDamageDealt = totalDamageDealt;
|
||||
|
||||
// 장착된 스킬 슬롯에서 사용 가능한 스킬 ID 목록 조회
|
||||
var availableSkillIds = state.skillSystem.equippedSkills.allSkills
|
||||
.map((s) => s.id)
|
||||
.toList();
|
||||
// 장착된 스킬이 없으면 기본 스킬 사용
|
||||
if (availableSkillIds.isEmpty) {
|
||||
availableSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
|
||||
final selectedSkill = skillService.selectAutoSkill(
|
||||
player: newPlayerStats,
|
||||
monster: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
availableSkillIds: availableSkillIds,
|
||||
activeDoTs: newActiveDoTs,
|
||||
activeDebuffs: newActiveBuffs,
|
||||
);
|
||||
|
||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||
// 스킬 랭크 조회
|
||||
final skillRank = skillService.getSkillRankFromSkillBook(
|
||||
state.skillBook,
|
||||
selectedSkill.id,
|
||||
);
|
||||
// 랭크 스케일링 적용된 공격 스킬 사용
|
||||
final skillResult = skillService.useAttackSkillWithRank(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
monster: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
rank: skillRank,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newMonsterStats = skillResult.updatedMonster;
|
||||
newTotalDamageDealt += skillResult.result.damage;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerSkill(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
damage: skillResult.result.damage,
|
||||
targetName: newMonsterStats.name,
|
||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||
final skillResult = skillService.useDotSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
playerInt: state.stats.intelligence,
|
||||
playerWis: state.stats.wis,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
if (skillResult.dotEffect != null) {
|
||||
newActiveDoTs.add(skillResult.dotEffect!);
|
||||
}
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerSkill(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
damage: skillResult.result.damage,
|
||||
targetName: newMonsterStats.name,
|
||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||
final skillResult = skillService.useHealSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
healingMultiplier: healingMultiplier,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerHeal(
|
||||
timestamp: timestamp,
|
||||
healAmount: skillResult.result.healedAmount,
|
||||
skillName: selectedSkill.name,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||
final skillResult = skillService.useBuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerBuff(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||
final skillResult = skillService.useDebuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
currentDebuffs: newActiveBuffs,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
if (skillResult.debuffEffect != null) {
|
||||
newActiveBuffs =
|
||||
newActiveBuffs
|
||||
.where(
|
||||
(d) => d.effect.id != skillResult.debuffEffect!.effect.id,
|
||||
)
|
||||
.toList()
|
||||
..add(skillResult.debuffEffect!);
|
||||
}
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerDebuff(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
targetName: newMonsterStats.name,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// 일반 공격
|
||||
final attackResult = calculator.playerAttackMonster(
|
||||
attacker: newPlayerStats,
|
||||
defender: newMonsterStats,
|
||||
);
|
||||
newMonsterStats = attackResult.updatedDefender;
|
||||
|
||||
// 첫 공격 배율 적용 (예: Pointer Assassin 1.5배)
|
||||
var damage = attackResult.result.damage;
|
||||
if (isFirstPlayerAttack && firstStrikeBonus > 1.0) {
|
||||
damage = (damage * firstStrikeBonus).round();
|
||||
// 첫 공격 배율이 적용된 데미지로 몬스터 HP 재계산
|
||||
final extraDamage = damage - attackResult.result.damage;
|
||||
if (extraDamage > 0) {
|
||||
final newHp = (newMonsterStats.hpCurrent - extraDamage).clamp(
|
||||
0,
|
||||
newMonsterStats.hpMax,
|
||||
);
|
||||
newMonsterStats = newMonsterStats.copyWith(hpCurrent: newHp);
|
||||
}
|
||||
}
|
||||
newTotalDamageDealt += damage;
|
||||
|
||||
final result = attackResult.result;
|
||||
if (result.isEvaded) {
|
||||
events.add(
|
||||
CombatEvent.monsterEvade(
|
||||
timestamp: timestamp,
|
||||
targetName: newMonsterStats.name,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
events.add(
|
||||
CombatEvent.playerAttack(
|
||||
timestamp: timestamp,
|
||||
damage: damage,
|
||||
targetName: newMonsterStats.name,
|
||||
isCritical: result.isCritical,
|
||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 연속 공격 (Refactor Monk 패시브) - 30% 확률로 추가 공격
|
||||
if (hasMultiAttack && newMonsterStats.isAlive && rng.nextDouble() < 0.3) {
|
||||
final extraAttack = calculator.playerAttackMonster(
|
||||
attacker: newPlayerStats,
|
||||
defender: newMonsterStats,
|
||||
);
|
||||
newMonsterStats = extraAttack.updatedDefender;
|
||||
newTotalDamageDealt += extraAttack.result.damage;
|
||||
|
||||
if (!extraAttack.result.isEvaded) {
|
||||
events.add(
|
||||
CombatEvent.playerAttack(
|
||||
timestamp: timestamp,
|
||||
damage: extraAttack.result.damage,
|
||||
targetName: newMonsterStats.name,
|
||||
isCritical: extraAttack.result.isCritical,
|
||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
playerStats: newPlayerStats,
|
||||
monsterStats: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
activeDoTs: newActiveDoTs,
|
||||
activeDebuffs: newActiveBuffs,
|
||||
totalDamageDealt: newTotalDamageDealt,
|
||||
events: events,
|
||||
isFirstPlayerAttack: false, // 첫 공격 이후에는 false
|
||||
);
|
||||
}
|
||||
|
||||
/// 몬스터 공격 처리
|
||||
({CombatStats playerStats, int totalDamageTaken, List<CombatEvent> events})
|
||||
_processMonsterAttack({
|
||||
|
||||
172
lib/src/core/engine/death_handler.dart
Normal file
172
lib/src/core/engine/death_handler.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
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/item_stats.dart';
|
||||
|
||||
/// 플레이어 사망 처리 서비스
|
||||
///
|
||||
/// ProgressService에서 분리된 사망 관련 로직 담당:
|
||||
/// - 장비 손실 계산
|
||||
/// - 사망 정보 기록
|
||||
/// - 보스전 레벨링 모드 진입
|
||||
class DeathHandler {
|
||||
const DeathHandler();
|
||||
|
||||
/// 플레이어 사망 처리 (Phase 4)
|
||||
///
|
||||
/// 모든 장비 상실 및 사망 정보 기록.
|
||||
/// 보스전 사망 시: 장비 보호 + 5분 레벨링 모드 진입.
|
||||
GameState processPlayerDeath(
|
||||
GameState state, {
|
||||
required String killerName,
|
||||
required DeathCause cause,
|
||||
}) {
|
||||
// 사망 직전 전투 이벤트 저장 (최대 10개)
|
||||
final lastCombatEvents =
|
||||
state.progress.currentCombat?.recentEvents ?? const [];
|
||||
|
||||
// 보스전 사망 여부 확인 (최종 보스 fighting 상태)
|
||||
final isBossDeath =
|
||||
state.progress.finalBossState == FinalBossState.fighting;
|
||||
|
||||
// 보스전 사망이 아닐 경우에만 장비 손실
|
||||
var newEquipment = state.equipment;
|
||||
var lostCount = 0;
|
||||
String? lostItemName;
|
||||
EquipmentSlot? lostItemSlot;
|
||||
ItemRarity? lostItemRarity;
|
||||
EquipmentItem? lostEquipmentItem; // 광고 부활 시 복구용
|
||||
|
||||
if (!isBossDeath) {
|
||||
final lossResult = _calculateEquipmentLoss(state);
|
||||
newEquipment = lossResult.equipment;
|
||||
lostCount = lossResult.lostCount;
|
||||
lostItemName = lossResult.lostItemName;
|
||||
lostItemSlot = lossResult.lostItemSlot;
|
||||
lostItemRarity = lossResult.lostItemRarity;
|
||||
lostEquipmentItem = lossResult.lostItem;
|
||||
}
|
||||
|
||||
// 사망 정보 생성 (전투 로그 포함)
|
||||
final deathInfo = DeathInfo(
|
||||
cause: cause,
|
||||
killerName: killerName,
|
||||
lostEquipmentCount: lostCount,
|
||||
lostItemName: lostItemName,
|
||||
lostItemSlot: lostItemSlot,
|
||||
lostItemRarity: lostItemRarity,
|
||||
lostItem: lostEquipmentItem,
|
||||
goldAtDeath: state.inventory.gold,
|
||||
levelAtDeath: state.traits.level,
|
||||
timestamp: state.skillSystem.elapsedMs,
|
||||
lastCombatEvents: lastCombatEvents,
|
||||
);
|
||||
|
||||
// 보스전 사망 시 5분 레벨링 모드 진입
|
||||
final bossLevelingEndTime = isBossDeath
|
||||
? DateTime.now().millisecondsSinceEpoch +
|
||||
(5 * 60 * 1000) // 5분
|
||||
: null;
|
||||
|
||||
// 전투 상태 초기화 및 사망 횟수 증가
|
||||
final progress = state.progress.copyWith(
|
||||
currentCombat: null,
|
||||
deathCount: state.progress.deathCount + 1,
|
||||
bossLevelingEndTime: bossLevelingEndTime,
|
||||
);
|
||||
|
||||
return state.copyWith(
|
||||
equipment: newEquipment,
|
||||
progress: progress,
|
||||
deathInfo: deathInfo,
|
||||
);
|
||||
}
|
||||
|
||||
/// 장비 손실 계산
|
||||
({
|
||||
Equipment equipment,
|
||||
int lostCount,
|
||||
String? lostItemName,
|
||||
EquipmentSlot? lostItemSlot,
|
||||
ItemRarity? lostItemRarity,
|
||||
EquipmentItem? lostItem,
|
||||
})
|
||||
_calculateEquipmentLoss(GameState state) {
|
||||
var newEquipment = state.equipment;
|
||||
|
||||
// 레벨 기반 장비 손실 확률 계산
|
||||
// Lv 1: 20%, Lv 5: ~56%, Lv 10+: 100%
|
||||
// 공식: 20 + (level - 1) * 80 / 9
|
||||
final level = state.traits.level;
|
||||
final lossChancePercent = level >= 10
|
||||
? 100
|
||||
: (20 + ((level - 1) * 80 ~/ 9)).clamp(0, 100);
|
||||
final roll = state.rng.nextInt(100); // 0~99
|
||||
final shouldLoseEquipment = roll < lossChancePercent;
|
||||
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'[Death] Lv$level lossChance=$lossChancePercent% roll=$roll '
|
||||
'shouldLose=$shouldLoseEquipment',
|
||||
);
|
||||
|
||||
if (!shouldLoseEquipment) {
|
||||
return (
|
||||
equipment: newEquipment,
|
||||
lostCount: 0,
|
||||
lostItemName: null,
|
||||
lostItemSlot: null,
|
||||
lostItemRarity: null,
|
||||
lostItem: null,
|
||||
);
|
||||
}
|
||||
|
||||
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
|
||||
final equippedNonWeaponSlots = <int>[];
|
||||
for (var i = 1; i < Equipment.slotCount; i++) {
|
||||
final item = state.equipment.getItemByIndex(i);
|
||||
if (item.isNotEmpty) {
|
||||
equippedNonWeaponSlots.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (equippedNonWeaponSlots.isEmpty) {
|
||||
return (
|
||||
equipment: newEquipment,
|
||||
lostCount: 0,
|
||||
lostItemName: null,
|
||||
lostItemSlot: null,
|
||||
lostItemRarity: null,
|
||||
lostItem: null,
|
||||
);
|
||||
}
|
||||
|
||||
// 랜덤하게 1개 슬롯 선택
|
||||
final sacrificeIndex =
|
||||
equippedNonWeaponSlots[state.rng.nextInt(
|
||||
equippedNonWeaponSlots.length,
|
||||
)];
|
||||
|
||||
// 제물로 바칠 아이템 정보 저장
|
||||
final lostItem = state.equipment.getItemByIndex(sacrificeIndex);
|
||||
final lostItemSlot = EquipmentSlot.values[sacrificeIndex];
|
||||
|
||||
// 해당 슬롯을 빈 장비로 교체
|
||||
newEquipment = newEquipment.setItemByIndex(
|
||||
sacrificeIndex,
|
||||
EquipmentItem.empty(lostItemSlot),
|
||||
);
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[Death] Lost item: ${lostItem.name} (slot: $lostItemSlot)');
|
||||
|
||||
return (
|
||||
equipment: newEquipment,
|
||||
lostCount: 1,
|
||||
lostItemName: lostItem.name,
|
||||
lostItemSlot: lostItemSlot,
|
||||
lostItemRarity: lostItem.rarity,
|
||||
lostItem: lostItem,
|
||||
);
|
||||
}
|
||||
}
|
||||
80
lib/src/core/engine/loot_handler.dart
Normal file
80
lib/src/core/engine/loot_handler.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/potion_service.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/potion.dart';
|
||||
|
||||
/// 전리품 처리 서비스
|
||||
///
|
||||
/// ProgressService에서 분리된 전리품 획득 로직 담당:
|
||||
/// - 몬스터 부위 아이템 인벤토리 추가
|
||||
/// - 특수 아이템 획득 (WinItem)
|
||||
/// - 물약 드랍
|
||||
class LootHandler {
|
||||
const LootHandler({required this.mutations});
|
||||
|
||||
final GameMutations mutations;
|
||||
|
||||
/// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630)
|
||||
({GameState state, Potion? droppedPotion}) winLoot(GameState state) {
|
||||
final taskInfo = state.progress.currentTask;
|
||||
final monsterPart = taskInfo.monsterPart ?? '';
|
||||
final monsterBaseName = taskInfo.monsterBaseName ?? '';
|
||||
|
||||
var resultState = state;
|
||||
|
||||
// 부위가 '*'이면 WinItem 호출 (특수 아이템)
|
||||
if (monsterPart == '*') {
|
||||
resultState = mutations.winItem(resultState);
|
||||
} else if (monsterPart.isNotEmpty && monsterBaseName.isNotEmpty) {
|
||||
// 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' +
|
||||
// ProperCase(Split(fTask.Caption,3))), 1);
|
||||
// 예: "goblin Claw" 형태로 인벤토리 추가
|
||||
final itemName =
|
||||
'${monsterBaseName.toLowerCase()} ${_properCase(monsterPart)}';
|
||||
|
||||
// 인벤토리에 추가
|
||||
final items = [...resultState.inventory.items];
|
||||
final existing = items.indexWhere((e) => e.name == itemName);
|
||||
if (existing >= 0) {
|
||||
items[existing] = items[existing].copyWith(
|
||||
count: items[existing].count + 1,
|
||||
);
|
||||
} else {
|
||||
items.add(InventoryEntry(name: itemName, count: 1));
|
||||
}
|
||||
|
||||
resultState = resultState.copyWith(
|
||||
inventory: resultState.inventory.copyWith(items: items),
|
||||
);
|
||||
}
|
||||
|
||||
// 물약 드랍 시도
|
||||
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),
|
||||
);
|
||||
|
||||
return (
|
||||
state: resultState.copyWith(
|
||||
rng: rng,
|
||||
potionInventory: updatedPotionInventory,
|
||||
),
|
||||
droppedPotion: droppedPotion,
|
||||
);
|
||||
}
|
||||
|
||||
/// 첫 글자만 대문자로 변환 (원본 ProperCase)
|
||||
String _properCase(String s) {
|
||||
if (s.isEmpty) return s;
|
||||
return s[0].toUpperCase() + s.substring(1);
|
||||
}
|
||||
}
|
||||
411
lib/src/core/engine/player_attack_processor.dart
Normal file
411
lib/src/core/engine/player_attack_processor.dart
Normal file
@@ -0,0 +1,411 @@
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.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/skill.dart';
|
||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// 플레이어 공격 처리 결과
|
||||
typedef PlayerAttackResult = ({
|
||||
CombatStats playerStats,
|
||||
MonsterCombatStats monsterStats,
|
||||
SkillSystemState skillSystem,
|
||||
List<DotEffect> activeDoTs,
|
||||
List<ActiveBuff> activeDebuffs,
|
||||
int totalDamageDealt,
|
||||
List<CombatEvent> events,
|
||||
bool isFirstPlayerAttack,
|
||||
});
|
||||
|
||||
/// 플레이어 공격 처리 서비스
|
||||
///
|
||||
/// CombatTickService에서 분리된 플레이어 공격 로직 담당:
|
||||
/// - 스킬 자동 선택 및 사용
|
||||
/// - 일반 공격 처리
|
||||
/// - 첫 공격 보너스
|
||||
/// - 연속 공격 (Multi-attack)
|
||||
class PlayerAttackProcessor {
|
||||
PlayerAttackProcessor({required this.rng});
|
||||
|
||||
final DeterministicRandom rng;
|
||||
|
||||
/// 플레이어 공격 처리
|
||||
PlayerAttackResult processAttack({
|
||||
required GameState state,
|
||||
required CombatStats playerStats,
|
||||
required MonsterCombatStats monsterStats,
|
||||
required SkillSystemState updatedSkillSystem,
|
||||
required List<DotEffect> activeDoTs,
|
||||
required List<ActiveBuff> activeDebuffs,
|
||||
required int totalDamageDealt,
|
||||
required int timestamp,
|
||||
required CombatCalculator calculator,
|
||||
required SkillService skillService,
|
||||
required bool isFirstPlayerAttack,
|
||||
required double firstStrikeBonus,
|
||||
required bool hasMultiAttack,
|
||||
double healingMultiplier = 1.0,
|
||||
}) {
|
||||
final events = <CombatEvent>[];
|
||||
var newPlayerStats = playerStats;
|
||||
var newMonsterStats = monsterStats;
|
||||
var newSkillSystem = updatedSkillSystem;
|
||||
var newActiveDoTs = [...activeDoTs];
|
||||
var newActiveBuffs = [...activeDebuffs];
|
||||
var newTotalDamageDealt = totalDamageDealt;
|
||||
|
||||
// 장착된 스킬 슬롯에서 사용 가능한 스킬 ID 목록 조회
|
||||
var availableSkillIds = state.skillSystem.equippedSkills.allSkills
|
||||
.map((s) => s.id)
|
||||
.toList();
|
||||
// 장착된 스킬이 없으면 기본 스킬 사용
|
||||
if (availableSkillIds.isEmpty) {
|
||||
availableSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
|
||||
final selectedSkill = skillService.selectAutoSkill(
|
||||
player: newPlayerStats,
|
||||
monster: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
availableSkillIds: availableSkillIds,
|
||||
activeDoTs: newActiveDoTs,
|
||||
activeDebuffs: newActiveBuffs,
|
||||
);
|
||||
|
||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||
final result = _useAttackSkill(
|
||||
state: state,
|
||||
skill: selectedSkill,
|
||||
playerStats: newPlayerStats,
|
||||
monsterStats: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
skillService: skillService,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
newPlayerStats = result.playerStats;
|
||||
newMonsterStats = result.monsterStats;
|
||||
newTotalDamageDealt += result.damage;
|
||||
newSkillSystem = result.skillSystem;
|
||||
events.add(result.event);
|
||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||
final result = _useDotSkill(
|
||||
state: state,
|
||||
skill: selectedSkill,
|
||||
playerStats: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
skillService: skillService,
|
||||
monsterName: newMonsterStats.name,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
newPlayerStats = result.playerStats;
|
||||
newSkillSystem = result.skillSystem;
|
||||
if (result.dotEffect != null) newActiveDoTs.add(result.dotEffect!);
|
||||
events.add(result.event);
|
||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||
final result = _useHealSkill(
|
||||
skill: selectedSkill,
|
||||
playerStats: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
skillService: skillService,
|
||||
healingMultiplier: healingMultiplier,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
newPlayerStats = result.playerStats;
|
||||
newSkillSystem = result.skillSystem;
|
||||
events.add(result.event);
|
||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||
final result = skillService.useBuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
);
|
||||
newPlayerStats = result.updatedPlayer;
|
||||
newSkillSystem = result.updatedSkillSystem.startGlobalCooldown();
|
||||
events.add(
|
||||
CombatEvent.playerBuff(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||
final result = _useDebuffSkill(
|
||||
skill: selectedSkill,
|
||||
playerStats: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
skillService: skillService,
|
||||
activeDebuffs: newActiveBuffs,
|
||||
monsterName: newMonsterStats.name,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
newPlayerStats = result.playerStats;
|
||||
newSkillSystem = result.skillSystem;
|
||||
newActiveBuffs = result.activeDebuffs;
|
||||
events.add(result.event);
|
||||
} else {
|
||||
// 일반 공격
|
||||
final result = _processNormalAttack(
|
||||
playerStats: newPlayerStats,
|
||||
monsterStats: newMonsterStats,
|
||||
calculator: calculator,
|
||||
isFirstPlayerAttack: isFirstPlayerAttack,
|
||||
firstStrikeBonus: firstStrikeBonus,
|
||||
hasMultiAttack: hasMultiAttack,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
newMonsterStats = result.monsterStats;
|
||||
newTotalDamageDealt += result.totalDamage;
|
||||
events.addAll(result.events);
|
||||
}
|
||||
|
||||
return (
|
||||
playerStats: newPlayerStats,
|
||||
monsterStats: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
activeDoTs: newActiveDoTs,
|
||||
activeDebuffs: newActiveBuffs,
|
||||
totalDamageDealt: newTotalDamageDealt,
|
||||
events: events,
|
||||
isFirstPlayerAttack: false,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 스킬 사용 헬퍼
|
||||
// ============================================================================
|
||||
|
||||
({
|
||||
CombatStats playerStats,
|
||||
MonsterCombatStats monsterStats,
|
||||
int damage,
|
||||
SkillSystemState skillSystem,
|
||||
CombatEvent event,
|
||||
})
|
||||
_useAttackSkill({
|
||||
required GameState state,
|
||||
required Skill skill,
|
||||
required CombatStats playerStats,
|
||||
required MonsterCombatStats monsterStats,
|
||||
required SkillSystemState skillSystem,
|
||||
required SkillService skillService,
|
||||
required int timestamp,
|
||||
}) {
|
||||
final skillRank = skillService.getSkillRankFromSkillBook(
|
||||
state.skillBook,
|
||||
skill.id,
|
||||
);
|
||||
final skillResult = skillService.useAttackSkillWithRank(
|
||||
skill: skill,
|
||||
player: playerStats,
|
||||
monster: monsterStats,
|
||||
skillSystem: skillSystem,
|
||||
rank: skillRank,
|
||||
);
|
||||
return (
|
||||
playerStats: skillResult.updatedPlayer,
|
||||
monsterStats: skillResult.updatedMonster,
|
||||
damage: skillResult.result.damage,
|
||||
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
|
||||
event: CombatEvent.playerSkill(
|
||||
timestamp: timestamp,
|
||||
skillName: skill.name,
|
||||
damage: skillResult.result.damage,
|
||||
targetName: monsterStats.name,
|
||||
attackDelayMs: playerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
({
|
||||
CombatStats playerStats,
|
||||
SkillSystemState skillSystem,
|
||||
DotEffect? dotEffect,
|
||||
CombatEvent event,
|
||||
})
|
||||
_useDotSkill({
|
||||
required GameState state,
|
||||
required Skill skill,
|
||||
required CombatStats playerStats,
|
||||
required SkillSystemState skillSystem,
|
||||
required SkillService skillService,
|
||||
required String monsterName,
|
||||
required int timestamp,
|
||||
}) {
|
||||
final skillResult = skillService.useDotSkill(
|
||||
skill: skill,
|
||||
player: playerStats,
|
||||
skillSystem: skillSystem,
|
||||
playerInt: state.stats.intelligence,
|
||||
playerWis: state.stats.wis,
|
||||
);
|
||||
return (
|
||||
playerStats: skillResult.updatedPlayer,
|
||||
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
|
||||
dotEffect: skillResult.dotEffect,
|
||||
event: CombatEvent.playerSkill(
|
||||
timestamp: timestamp,
|
||||
skillName: skill.name,
|
||||
damage: skillResult.result.damage,
|
||||
targetName: monsterName,
|
||||
attackDelayMs: playerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
({CombatStats playerStats, SkillSystemState skillSystem, CombatEvent event})
|
||||
_useHealSkill({
|
||||
required Skill skill,
|
||||
required CombatStats playerStats,
|
||||
required SkillSystemState skillSystem,
|
||||
required SkillService skillService,
|
||||
required double healingMultiplier,
|
||||
required int timestamp,
|
||||
}) {
|
||||
final skillResult = skillService.useHealSkill(
|
||||
skill: skill,
|
||||
player: playerStats,
|
||||
skillSystem: skillSystem,
|
||||
healingMultiplier: healingMultiplier,
|
||||
);
|
||||
return (
|
||||
playerStats: skillResult.updatedPlayer,
|
||||
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
|
||||
event: CombatEvent.playerHeal(
|
||||
timestamp: timestamp,
|
||||
healAmount: skillResult.result.healedAmount,
|
||||
skillName: skill.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
({
|
||||
CombatStats playerStats,
|
||||
SkillSystemState skillSystem,
|
||||
List<ActiveBuff> activeDebuffs,
|
||||
CombatEvent event,
|
||||
})
|
||||
_useDebuffSkill({
|
||||
required Skill skill,
|
||||
required CombatStats playerStats,
|
||||
required SkillSystemState skillSystem,
|
||||
required SkillService skillService,
|
||||
required List<ActiveBuff> activeDebuffs,
|
||||
required String monsterName,
|
||||
required int timestamp,
|
||||
}) {
|
||||
final skillResult = skillService.useDebuffSkill(
|
||||
skill: skill,
|
||||
player: playerStats,
|
||||
skillSystem: skillSystem,
|
||||
currentDebuffs: activeDebuffs,
|
||||
);
|
||||
var newDebuffs = activeDebuffs;
|
||||
if (skillResult.debuffEffect != null) {
|
||||
newDebuffs =
|
||||
activeDebuffs
|
||||
.where((d) => d.effect.id != skillResult.debuffEffect!.effect.id)
|
||||
.toList()
|
||||
..add(skillResult.debuffEffect!);
|
||||
}
|
||||
return (
|
||||
playerStats: skillResult.updatedPlayer,
|
||||
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
|
||||
activeDebuffs: newDebuffs,
|
||||
event: CombatEvent.playerDebuff(
|
||||
timestamp: timestamp,
|
||||
skillName: skill.name,
|
||||
targetName: monsterName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 일반 공격
|
||||
// ============================================================================
|
||||
|
||||
({MonsterCombatStats monsterStats, int totalDamage, List<CombatEvent> events})
|
||||
_processNormalAttack({
|
||||
required CombatStats playerStats,
|
||||
required MonsterCombatStats monsterStats,
|
||||
required CombatCalculator calculator,
|
||||
required bool isFirstPlayerAttack,
|
||||
required double firstStrikeBonus,
|
||||
required bool hasMultiAttack,
|
||||
required int timestamp,
|
||||
}) {
|
||||
final events = <CombatEvent>[];
|
||||
var newMonsterStats = monsterStats;
|
||||
var totalDamage = 0;
|
||||
|
||||
final attackResult = calculator.playerAttackMonster(
|
||||
attacker: playerStats,
|
||||
defender: newMonsterStats,
|
||||
);
|
||||
newMonsterStats = attackResult.updatedDefender;
|
||||
|
||||
// 첫 공격 배율 적용
|
||||
var damage = attackResult.result.damage;
|
||||
if (isFirstPlayerAttack && firstStrikeBonus > 1.0) {
|
||||
damage = (damage * firstStrikeBonus).round();
|
||||
final extraDamage = damage - attackResult.result.damage;
|
||||
if (extraDamage > 0) {
|
||||
final newHp = (newMonsterStats.hpCurrent - extraDamage).clamp(
|
||||
0,
|
||||
newMonsterStats.hpMax,
|
||||
);
|
||||
newMonsterStats = newMonsterStats.copyWith(hpCurrent: newHp);
|
||||
}
|
||||
}
|
||||
totalDamage += damage;
|
||||
|
||||
final result = attackResult.result;
|
||||
if (result.isEvaded) {
|
||||
events.add(
|
||||
CombatEvent.monsterEvade(
|
||||
timestamp: timestamp,
|
||||
targetName: newMonsterStats.name,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
events.add(
|
||||
CombatEvent.playerAttack(
|
||||
timestamp: timestamp,
|
||||
damage: damage,
|
||||
targetName: newMonsterStats.name,
|
||||
isCritical: result.isCritical,
|
||||
attackDelayMs: playerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 연속 공격 (Refactor Monk 패시브) - 30% 확률로 추가 공격
|
||||
if (hasMultiAttack && newMonsterStats.isAlive && rng.nextDouble() < 0.3) {
|
||||
final extraAttack = calculator.playerAttackMonster(
|
||||
attacker: playerStats,
|
||||
defender: newMonsterStats,
|
||||
);
|
||||
newMonsterStats = extraAttack.updatedDefender;
|
||||
totalDamage += extraAttack.result.damage;
|
||||
|
||||
if (!extraAttack.result.isEvaded) {
|
||||
events.add(
|
||||
CombatEvent.playerAttack(
|
||||
timestamp: timestamp,
|
||||
damage: extraAttack.result.damage,
|
||||
targetName: newMonsterStats.name,
|
||||
isCritical: extraAttack.result.isCritical,
|
||||
attackDelayMs: playerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
monsterStats: newMonsterStats,
|
||||
totalDamage: totalDamage,
|
||||
events: events,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,18 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:asciineverdie/data/class_data.dart';
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/data/race_data.dart';
|
||||
import 'package:asciineverdie/src/core/model/class_traits.dart';
|
||||
import 'package:asciineverdie/src/core/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/core/engine/act_progression_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_tick_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/death_handler.dart';
|
||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/loot_handler.dart';
|
||||
import 'package:asciineverdie/src/core/engine/market_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/potion_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/task_generator.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/combat_stats.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/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/item_stats.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/util/balance_constants.dart';
|
||||
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||
@@ -58,12 +48,17 @@ class ProgressService {
|
||||
required this.config,
|
||||
required this.mutations,
|
||||
required this.rewards,
|
||||
});
|
||||
}) : _taskGenerator = TaskGenerator(config: config),
|
||||
_lootHandler = LootHandler(mutations: mutations);
|
||||
|
||||
final PqConfig config;
|
||||
final GameMutations mutations;
|
||||
final RewardService rewards;
|
||||
|
||||
final TaskGenerator _taskGenerator;
|
||||
final LootHandler _lootHandler;
|
||||
static const _deathHandler = DeathHandler();
|
||||
|
||||
/// 새 게임 초기화 (원본 GoButtonClick, Main.pas:741-767)
|
||||
/// Prologue 태스크들을 큐에 추가하고 첫 태스크 시작
|
||||
GameState initializeNewGame(GameState state) {
|
||||
@@ -318,7 +313,7 @@ class ProgressService {
|
||||
// 플레이어 사망 체크
|
||||
if (!updatedCombat.playerStats.isAlive) {
|
||||
final monsterName = updatedCombat.monsterStats.name;
|
||||
nextState = _processPlayerDeath(
|
||||
nextState = _deathHandler.processPlayerDeath(
|
||||
state,
|
||||
killerName: monsterName,
|
||||
cause: DeathCause.monster,
|
||||
@@ -380,7 +375,7 @@ class ProgressService {
|
||||
}
|
||||
|
||||
// 전리품 획득
|
||||
final lootResult = _winLoot(nextState);
|
||||
final lootResult = _lootHandler.winLoot(nextState);
|
||||
nextState = lootResult.state;
|
||||
|
||||
// 물약 드랍 로그 추가
|
||||
@@ -636,7 +631,7 @@ class ProgressService {
|
||||
}
|
||||
} else {
|
||||
nextState = nextState.copyWith(progress: progress, queue: queue);
|
||||
final newTaskResult = _generateNextTask(nextState);
|
||||
final newTaskResult = _taskGenerator.generateNextTask(nextState);
|
||||
progress = newTaskResult.progress;
|
||||
queue = newTaskResult.queue;
|
||||
}
|
||||
@@ -650,241 +645,6 @@ class ProgressService {
|
||||
);
|
||||
}
|
||||
|
||||
/// 큐가 비어있을 때 다음 태스크 생성 (원본 Dequeue 667-684줄)
|
||||
({ProgressState progress, QueueState queue}) _generateNextTask(
|
||||
GameState state,
|
||||
) {
|
||||
var progress = state.progress;
|
||||
final queue = state.queue;
|
||||
final oldTaskType = progress.currentTask.type;
|
||||
|
||||
// 1. Encumbrance 초과 시 시장 이동
|
||||
if (_shouldGoToMarket(progress)) {
|
||||
return _createMarketTask(progress, queue);
|
||||
}
|
||||
|
||||
// 2. 전환 태스크 (buying/heading)
|
||||
if (_needsTransitionTask(oldTaskType)) {
|
||||
return _createTransitionTask(state, progress, queue);
|
||||
}
|
||||
|
||||
// 3. Act Boss 리트라이
|
||||
if (state.progress.pendingActCompletion) {
|
||||
return _createActBossRetryTask(state, progress, queue);
|
||||
}
|
||||
|
||||
// 4. 최종 보스 전투
|
||||
if (state.progress.finalBossState == FinalBossState.fighting &&
|
||||
!state.progress.isInBossLevelingMode) {
|
||||
if (state.progress.bossLevelingEndTime != null) {
|
||||
progress = progress.copyWith(clearBossLevelingEndTime: true);
|
||||
}
|
||||
final actProgressionService = ActProgressionService(config: config);
|
||||
return actProgressionService.startFinalBossFight(state, progress, queue);
|
||||
}
|
||||
|
||||
// 5. 일반 몬스터 전투
|
||||
return _createMonsterTask(state, progress, queue);
|
||||
}
|
||||
|
||||
/// 시장 이동 조건 확인
|
||||
bool _shouldGoToMarket(ProgressState progress) {
|
||||
return progress.encumbrance.position >= progress.encumbrance.max &&
|
||||
progress.encumbrance.max > 0;
|
||||
}
|
||||
|
||||
/// 전환 태스크 필요 여부 확인
|
||||
bool _needsTransitionTask(TaskType oldTaskType) {
|
||||
return oldTaskType != TaskType.kill &&
|
||||
oldTaskType != TaskType.neutral &&
|
||||
oldTaskType != TaskType.buying;
|
||||
}
|
||||
|
||||
/// 시장 이동 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createMarketTask(
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskHeadingToMarket(),
|
||||
4 * 1000,
|
||||
);
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.market),
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// 전환 태스크 생성 (buying 또는 heading)
|
||||
({ProgressState progress, QueueState queue}) _createTransitionTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final gold = state.inventory.gold;
|
||||
final equipPrice = state.traits.level * 50;
|
||||
|
||||
// Gold 충분 시 장비 구매
|
||||
if (gold > equipPrice) {
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskUpgradingHardware(),
|
||||
5 * 1000,
|
||||
);
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.buying,
|
||||
),
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
// Gold 부족 시 전장 이동
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskEnteringDebugZone(),
|
||||
4 * 1000,
|
||||
);
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.neutral,
|
||||
),
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// Act Boss 재도전 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createActBossRetryTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final actProgressionService = ActProgressionService(config: config);
|
||||
final actBoss = actProgressionService.createActBoss(state);
|
||||
final combatCalculator = CombatCalculator(rng: state.rng);
|
||||
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
||||
player: actBoss.playerStats,
|
||||
monster: actBoss.monsterStats,
|
||||
);
|
||||
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskDebugging(actBoss.monsterStats.name),
|
||||
durationMillis,
|
||||
);
|
||||
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.kill,
|
||||
monsterBaseName: actBoss.monsterStats.name,
|
||||
monsterPart: '*',
|
||||
monsterLevel: actBoss.monsterStats.level,
|
||||
monsterGrade: MonsterGrade.boss,
|
||||
monsterSize: getBossSizeForAct(state.progress.plotStageCount),
|
||||
),
|
||||
currentCombat: actBoss,
|
||||
);
|
||||
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// 일반 몬스터 전투 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createMonsterTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final level = state.traits.level;
|
||||
|
||||
// 퀘스트 몬스터 데이터 확인
|
||||
final questMonster = state.progress.currentQuestMonster;
|
||||
final questMonsterData = questMonster?.monsterData;
|
||||
final questLevel = questMonsterData != null
|
||||
? int.tryParse(questMonsterData.split('|').elementAtOrNull(1) ?? '') ??
|
||||
0
|
||||
: null;
|
||||
|
||||
// 몬스터 생성
|
||||
final monsterResult = pq_logic.monsterTask(
|
||||
config,
|
||||
state.rng,
|
||||
level,
|
||||
questMonsterData,
|
||||
questLevel,
|
||||
);
|
||||
|
||||
// 몬스터 레벨 조정 (밸런스)
|
||||
final actMinLevel = ActMonsterLevel.forPlotStage(
|
||||
state.progress.plotStageCount,
|
||||
);
|
||||
final baseLevel = math.max(level, actMinLevel);
|
||||
final effectiveMonsterLevel = monsterResult.level
|
||||
.clamp(math.max(1, baseLevel - 3), baseLevel + 3)
|
||||
.toInt();
|
||||
|
||||
// 전투 스탯 생성
|
||||
final playerCombatStats = CombatStats.fromStats(
|
||||
stats: state.stats,
|
||||
equipment: state.equipment,
|
||||
level: level,
|
||||
monsterLevel: effectiveMonsterLevel,
|
||||
);
|
||||
|
||||
final monsterCombatStats = MonsterCombatStats.fromLevel(
|
||||
name: monsterResult.displayName,
|
||||
level: effectiveMonsterLevel,
|
||||
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
|
||||
plotStageCount: state.progress.plotStageCount,
|
||||
);
|
||||
|
||||
// 전투 상태 및 지속시간
|
||||
final combatState = CombatState.start(
|
||||
playerStats: playerCombatStats,
|
||||
monsterStats: monsterCombatStats,
|
||||
);
|
||||
|
||||
final combatCalculator = CombatCalculator(rng: state.rng);
|
||||
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
||||
player: playerCombatStats,
|
||||
monster: monsterCombatStats,
|
||||
);
|
||||
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskDebugging(monsterResult.displayName),
|
||||
durationMillis,
|
||||
);
|
||||
|
||||
// 몬스터 사이즈 결정
|
||||
final monsterSize = getMonsterSizeForAct(
|
||||
plotStageCount: state.progress.plotStageCount,
|
||||
grade: monsterResult.grade,
|
||||
rng: state.rng,
|
||||
);
|
||||
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.kill,
|
||||
monsterBaseName: monsterResult.baseName,
|
||||
monsterPart: monsterResult.part,
|
||||
monsterLevel: effectiveMonsterLevel,
|
||||
monsterGrade: monsterResult.grade,
|
||||
monsterSize: monsterSize,
|
||||
),
|
||||
currentCombat: combatState,
|
||||
);
|
||||
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// Advances quest completion, applies reward, and enqueues next quest task.
|
||||
GameState completeQuest(GameState state) {
|
||||
final result = pq_logic.completeQuest(
|
||||
@@ -1069,184 +829,4 @@ class ProgressService {
|
||||
final progress = state.progress.copyWith(encumbrance: encumBar);
|
||||
return state.copyWith(progress: progress);
|
||||
}
|
||||
|
||||
/// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630)
|
||||
/// 전리품 획득 결과
|
||||
///
|
||||
/// [state] 업데이트된 게임 상태
|
||||
/// [droppedPotion] 드랍된 물약 (없으면 null)
|
||||
({GameState state, Potion? droppedPotion}) _winLoot(GameState state) {
|
||||
final taskInfo = state.progress.currentTask;
|
||||
final monsterPart = taskInfo.monsterPart ?? '';
|
||||
final monsterBaseName = taskInfo.monsterBaseName ?? '';
|
||||
|
||||
var resultState = state;
|
||||
|
||||
// 부위가 '*'이면 WinItem 호출 (특수 아이템)
|
||||
if (monsterPart == '*') {
|
||||
resultState = mutations.winItem(resultState);
|
||||
} else if (monsterPart.isNotEmpty && monsterBaseName.isNotEmpty) {
|
||||
// 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' +
|
||||
// ProperCase(Split(fTask.Caption,3))), 1);
|
||||
// 예: "goblin Claw" 형태로 인벤토리 추가
|
||||
final itemName =
|
||||
'${monsterBaseName.toLowerCase()} ${_properCase(monsterPart)}';
|
||||
|
||||
// 인벤토리에 추가
|
||||
final items = [...resultState.inventory.items];
|
||||
final existing = items.indexWhere((e) => e.name == itemName);
|
||||
if (existing >= 0) {
|
||||
items[existing] = items[existing].copyWith(
|
||||
count: items[existing].count + 1,
|
||||
);
|
||||
} else {
|
||||
items.add(InventoryEntry(name: itemName, count: 1));
|
||||
}
|
||||
|
||||
resultState = resultState.copyWith(
|
||||
inventory: resultState.inventory.copyWith(items: items),
|
||||
);
|
||||
}
|
||||
|
||||
// 물약 드랍 시도
|
||||
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),
|
||||
);
|
||||
|
||||
return (
|
||||
state: resultState.copyWith(
|
||||
rng: rng,
|
||||
potionInventory: updatedPotionInventory,
|
||||
),
|
||||
droppedPotion: droppedPotion,
|
||||
);
|
||||
}
|
||||
|
||||
/// 첫 글자만 대문자로 변환 (원본 ProperCase)
|
||||
String _properCase(String s) {
|
||||
if (s.isEmpty) return s;
|
||||
return s[0].toUpperCase() + s.substring(1);
|
||||
}
|
||||
|
||||
/// 플레이어 사망 처리 (Phase 4)
|
||||
///
|
||||
/// 모든 장비 상실 및 사망 정보 기록
|
||||
/// 보스전 사망 시: 장비 보호 + 5분 레벨링 모드 진입
|
||||
GameState _processPlayerDeath(
|
||||
GameState state, {
|
||||
required String killerName,
|
||||
required DeathCause cause,
|
||||
}) {
|
||||
// 사망 직전 전투 이벤트 저장 (최대 10개)
|
||||
final lastCombatEvents =
|
||||
state.progress.currentCombat?.recentEvents ?? const [];
|
||||
|
||||
// 보스전 사망 여부 확인 (최종 보스 fighting 상태)
|
||||
final isBossDeath =
|
||||
state.progress.finalBossState == FinalBossState.fighting;
|
||||
|
||||
// 보스전 사망이 아닐 경우에만 장비 손실
|
||||
var newEquipment = state.equipment;
|
||||
var lostCount = 0;
|
||||
String? lostItemName;
|
||||
EquipmentSlot? lostItemSlot;
|
||||
ItemRarity? lostItemRarity;
|
||||
EquipmentItem? lostEquipmentItem; // 광고 부활 시 복구용
|
||||
|
||||
if (!isBossDeath) {
|
||||
// 레벨 기반 장비 손실 확률 계산
|
||||
// Lv 1: 20%, Lv 5: ~56%, Lv 10+: 100%
|
||||
// 공식: 20 + (level - 1) * 80 / 9
|
||||
final level = state.traits.level;
|
||||
final lossChancePercent = level >= 10
|
||||
? 100
|
||||
: (20 + ((level - 1) * 80 ~/ 9)).clamp(0, 100);
|
||||
final roll = state.rng.nextInt(100); // 0~99
|
||||
final shouldLoseEquipment = roll < lossChancePercent;
|
||||
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'[Death] Lv$level lossChance=$lossChancePercent% roll=$roll '
|
||||
'shouldLose=$shouldLoseEquipment',
|
||||
);
|
||||
|
||||
if (shouldLoseEquipment) {
|
||||
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
|
||||
final equippedNonWeaponSlots = <int>[];
|
||||
for (var i = 1; i < Equipment.slotCount; i++) {
|
||||
final item = state.equipment.getItemByIndex(i);
|
||||
if (item.isNotEmpty) {
|
||||
equippedNonWeaponSlots.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (equippedNonWeaponSlots.isNotEmpty) {
|
||||
lostCount = 1;
|
||||
// 랜덤하게 1개 슬롯 선택
|
||||
final sacrificeIndex =
|
||||
equippedNonWeaponSlots[state.rng.nextInt(
|
||||
equippedNonWeaponSlots.length,
|
||||
)];
|
||||
|
||||
// 제물로 바칠 아이템 정보 저장
|
||||
lostEquipmentItem = state.equipment.getItemByIndex(sacrificeIndex);
|
||||
lostItemName = lostEquipmentItem.name;
|
||||
lostItemSlot = EquipmentSlot.values[sacrificeIndex];
|
||||
lostItemRarity = lostEquipmentItem.rarity;
|
||||
|
||||
// 해당 슬롯을 빈 장비로 교체
|
||||
newEquipment = newEquipment.setItemByIndex(
|
||||
sacrificeIndex,
|
||||
EquipmentItem.empty(lostItemSlot),
|
||||
);
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[Death] Lost item: $lostItemName (slot: $lostItemSlot)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 사망 정보 생성 (전투 로그 포함)
|
||||
final deathInfo = DeathInfo(
|
||||
cause: cause,
|
||||
killerName: killerName,
|
||||
lostEquipmentCount: lostCount,
|
||||
lostItemName: lostItemName,
|
||||
lostItemSlot: lostItemSlot,
|
||||
lostItemRarity: lostItemRarity,
|
||||
lostItem: lostEquipmentItem, // 광고 부활 시 복구용
|
||||
goldAtDeath: state.inventory.gold,
|
||||
levelAtDeath: state.traits.level,
|
||||
timestamp: state.skillSystem.elapsedMs,
|
||||
lastCombatEvents: lastCombatEvents,
|
||||
);
|
||||
|
||||
// 보스전 사망 시 5분 레벨링 모드 진입
|
||||
final bossLevelingEndTime = isBossDeath
|
||||
? DateTime.now().millisecondsSinceEpoch +
|
||||
(5 * 60 * 1000) // 5분
|
||||
: null;
|
||||
|
||||
// 전투 상태 초기화 및 사망 횟수 증가
|
||||
final progress = state.progress.copyWith(
|
||||
currentCombat: null,
|
||||
deathCount: state.progress.deathCount + 1,
|
||||
bossLevelingEndTime: bossLevelingEndTime,
|
||||
);
|
||||
|
||||
return state.copyWith(
|
||||
equipment: newEquipment,
|
||||
progress: progress,
|
||||
deathInfo: deathInfo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
200
lib/src/core/engine/skill_auto_selector.dart
Normal file
200
lib/src/core/engine/skill_auto_selector.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.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/skill.dart';
|
||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// 스킬 자동 선택 AI
|
||||
///
|
||||
/// SkillService에서 분리된 전투 중 스킬 자동 선택 로직.
|
||||
/// 상황별 우선순위에 따라 최적의 스킬을 선택한다.
|
||||
class SkillAutoSelector {
|
||||
const SkillAutoSelector({required this.rng});
|
||||
|
||||
final DeterministicRandom rng;
|
||||
|
||||
/// 전투 중 자동 스킬 선택
|
||||
///
|
||||
/// 우선순위:
|
||||
/// 1. HP < 30% -> 회복 스킬 (최우선)
|
||||
/// 2. 70% 확률로 일반 공격 (MP 절약, 기본 전투)
|
||||
/// 3. 30% 확률로 스킬 사용:
|
||||
/// - 버프: HP > 80% & MP > 60% & 활성 버프 없음
|
||||
/// - 디버프: 몬스터 HP > 80% & 활성 디버프 없음
|
||||
/// - DOT: 몬스터 HP > 60% & 활성 DOT 없음
|
||||
/// - 공격: 보스전이면 강력한 스킬, 일반전이면 효율적 스킬
|
||||
/// 4. MP < 20% -> 일반 공격
|
||||
Skill? selectAutoSkill({
|
||||
required CombatStats player,
|
||||
required MonsterCombatStats monster,
|
||||
required SkillSystemState skillSystem,
|
||||
required List<String> availableSkillIds,
|
||||
required bool Function(Skill) canUse,
|
||||
List<DotEffect> activeDoTs = const [],
|
||||
List<ActiveBuff> activeDebuffs = const [],
|
||||
}) {
|
||||
final mpRatio = player.mpRatio;
|
||||
final hpRatio = player.hpRatio;
|
||||
|
||||
// MP 20% 미만이면 일반 공격
|
||||
if (mpRatio < 0.2) return null;
|
||||
|
||||
// 사용 가능한 스킬 필터링
|
||||
final availableSkills = availableSkillIds
|
||||
.map((id) => SkillData.getSkillById(id))
|
||||
.whereType<Skill>()
|
||||
.where(canUse)
|
||||
.toList();
|
||||
|
||||
if (availableSkills.isEmpty) return null;
|
||||
|
||||
// HP < 30% -> 회복 스킬 최우선 (생존)
|
||||
if (hpRatio < 0.3) {
|
||||
final healSkill = _findBestHealSkill(availableSkills, player.mpCurrent);
|
||||
if (healSkill != null) return healSkill;
|
||||
}
|
||||
|
||||
// 70% 확률로 일반 공격 (스킬은 특별한 상황에서만)
|
||||
final useNormalAttack = rng.nextInt(100) < 70;
|
||||
if (useNormalAttack) return null;
|
||||
|
||||
// === 아래부터 30% 확률로 스킬 사용 ===
|
||||
|
||||
// 버프: HP > 80% & MP > 60% (매우 안전할 때만)
|
||||
if (hpRatio > 0.8 && mpRatio > 0.6) {
|
||||
final hasActiveBuff = skillSystem.activeBuffs.isNotEmpty;
|
||||
if (!hasActiveBuff) {
|
||||
final buffSkill = _findBestBuffSkill(availableSkills, player.mpCurrent);
|
||||
if (buffSkill != null) return buffSkill;
|
||||
}
|
||||
}
|
||||
|
||||
// 디버프: 몬스터 HP > 80% & 활성 디버프 없음 (전투 초반)
|
||||
if (monster.hpRatio > 0.8 && activeDebuffs.isEmpty) {
|
||||
final debuffSkill = _findBestDebuffSkill(
|
||||
availableSkills,
|
||||
player.mpCurrent,
|
||||
);
|
||||
if (debuffSkill != null) return debuffSkill;
|
||||
}
|
||||
|
||||
// DOT: 몬스터 HP > 60% & 활성 DOT 없음 (장기전 유리)
|
||||
if (monster.hpRatio > 0.6 && activeDoTs.isEmpty) {
|
||||
final dotSkill = _findBestDotSkill(availableSkills, player.mpCurrent);
|
||||
if (dotSkill != null) return dotSkill;
|
||||
}
|
||||
|
||||
// 보스전 판단 (몬스터 레벨 20 이상 & HP 50% 이상)
|
||||
final isBossFight = monster.level >= 20 && monster.hpRatio > 0.5;
|
||||
|
||||
if (isBossFight) {
|
||||
return _findStrongestAttackSkill(availableSkills);
|
||||
}
|
||||
|
||||
// 일반 전투 -> MP 효율 좋은 공격 스킬
|
||||
return _findEfficientAttackSkill(availableSkills);
|
||||
}
|
||||
|
||||
/// 가장 좋은 DOT 스킬 찾기
|
||||
Skill? _findBestDotSkill(List<Skill> skills, int currentMp) {
|
||||
final dotSkills = skills
|
||||
.where((s) => s.isDot && s.mpCost <= currentMp)
|
||||
.toList();
|
||||
|
||||
if (dotSkills.isEmpty) return null;
|
||||
|
||||
dotSkills.sort((a, b) {
|
||||
final aTotal =
|
||||
(a.baseDotDamage ?? 0) *
|
||||
((a.baseDotDurationMs ?? 0) ~/ (a.baseDotTickMs ?? 1000));
|
||||
final bTotal =
|
||||
(b.baseDotDamage ?? 0) *
|
||||
((b.baseDotDurationMs ?? 0) ~/ (b.baseDotTickMs ?? 1000));
|
||||
return bTotal.compareTo(aTotal);
|
||||
});
|
||||
|
||||
return dotSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 좋은 회복 스킬 찾기
|
||||
Skill? _findBestHealSkill(List<Skill> skills, int currentMp) {
|
||||
final healSkills = skills
|
||||
.where((s) => s.isHeal && s.mpCost <= currentMp)
|
||||
.toList();
|
||||
|
||||
if (healSkills.isEmpty) return null;
|
||||
|
||||
healSkills.sort((a, b) {
|
||||
final aValue = a.healPercent * 100 + a.healAmount;
|
||||
final bValue = b.healPercent * 100 + b.healAmount;
|
||||
return bValue.compareTo(aValue);
|
||||
});
|
||||
|
||||
return healSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 강력한 공격 스킬 찾기
|
||||
Skill? _findStrongestAttackSkill(List<Skill> skills) {
|
||||
final attackSkills = skills.where((s) => s.isAttack).toList();
|
||||
if (attackSkills.isEmpty) return null;
|
||||
|
||||
attackSkills.sort(
|
||||
(a, b) => b.damageMultiplier.compareTo(a.damageMultiplier),
|
||||
);
|
||||
return attackSkills.first;
|
||||
}
|
||||
|
||||
/// MP 효율 좋은 공격 스킬 찾기
|
||||
Skill? _findEfficientAttackSkill(List<Skill> skills) {
|
||||
final attackSkills = skills.where((s) => s.isAttack).toList();
|
||||
if (attackSkills.isEmpty) return null;
|
||||
|
||||
attackSkills.sort((a, b) => b.mpEfficiency.compareTo(a.mpEfficiency));
|
||||
return attackSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 좋은 버프 스킬 찾기
|
||||
Skill? _findBestBuffSkill(List<Skill> skills, int currentMp) {
|
||||
final buffSkills = skills
|
||||
.where((s) => s.isBuff && s.mpCost <= currentMp && s.buff != null)
|
||||
.toList();
|
||||
|
||||
if (buffSkills.isEmpty) return null;
|
||||
|
||||
buffSkills.sort((a, b) {
|
||||
final aValue =
|
||||
(a.buff?.atkModifier ?? 0) +
|
||||
(a.buff?.defModifier ?? 0) * 0.5 +
|
||||
(a.buff?.criRateModifier ?? 0) * 0.3;
|
||||
final bValue =
|
||||
(b.buff?.atkModifier ?? 0) +
|
||||
(b.buff?.defModifier ?? 0) * 0.5 +
|
||||
(b.buff?.criRateModifier ?? 0) * 0.3;
|
||||
return bValue.compareTo(aValue);
|
||||
});
|
||||
|
||||
return buffSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 좋은 디버프 스킬 찾기
|
||||
Skill? _findBestDebuffSkill(List<Skill> skills, int currentMp) {
|
||||
final debuffSkills = skills
|
||||
.where((s) => s.isDebuff && s.mpCost <= currentMp && s.buff != null)
|
||||
.toList();
|
||||
|
||||
if (debuffSkills.isEmpty) return null;
|
||||
|
||||
debuffSkills.sort((a, b) {
|
||||
final aValue =
|
||||
(a.buff?.atkModifier ?? 0).abs() +
|
||||
(a.buff?.defModifier ?? 0).abs() * 0.5;
|
||||
final bValue =
|
||||
(b.buff?.atkModifier ?? 0).abs() +
|
||||
(b.buff?.defModifier ?? 0).abs() * 0.5;
|
||||
return bValue.compareTo(aValue);
|
||||
});
|
||||
|
||||
return debuffSkills.first;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_auto_selector.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
@@ -309,20 +310,12 @@ class SkillService {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 자동 스킬 선택
|
||||
// 자동 스킬 선택 (SkillAutoSelector에 위임)
|
||||
// ============================================================================
|
||||
|
||||
/// 전투 중 자동 스킬 선택
|
||||
///
|
||||
/// 우선순위:
|
||||
/// 1. HP < 30% → 회복 스킬 (최우선)
|
||||
/// 2. 70% 확률로 일반 공격 (MP 절약, 기본 전투)
|
||||
/// 3. 30% 확률로 스킬 사용:
|
||||
/// - 버프: HP > 80% & MP > 60% & 활성 버프 없음
|
||||
/// - 디버프: 몬스터 HP > 80% & 활성 디버프 없음
|
||||
/// - DOT: 몬스터 HP > 60% & 활성 DOT 없음
|
||||
/// - 공격: 보스전이면 강력한 스킬, 일반전이면 효율적 스킬
|
||||
/// 4. MP < 20% → 일반 공격
|
||||
/// 세부 로직은 SkillAutoSelector에 위임.
|
||||
Skill? selectAutoSkill({
|
||||
required CombatStats player,
|
||||
required MonsterCombatStats monster,
|
||||
@@ -331,186 +324,22 @@ class SkillService {
|
||||
List<DotEffect> activeDoTs = const [],
|
||||
List<ActiveBuff> activeDebuffs = const [],
|
||||
}) {
|
||||
final currentMp = player.mpCurrent;
|
||||
final mpRatio = player.mpRatio;
|
||||
final hpRatio = player.hpRatio;
|
||||
|
||||
// MP 20% 미만이면 일반 공격
|
||||
if (mpRatio < 0.2) return null;
|
||||
|
||||
// 사용 가능한 스킬 필터링
|
||||
final availableSkills = availableSkillIds
|
||||
.map((id) => SkillData.getSkillById(id))
|
||||
.whereType<Skill>()
|
||||
.where(
|
||||
(skill) =>
|
||||
canUseSkill(
|
||||
skill: skill,
|
||||
currentMp: currentMp,
|
||||
skillSystem: skillSystem,
|
||||
) ==
|
||||
null,
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (availableSkills.isEmpty) return null;
|
||||
|
||||
// HP < 30% → 회복 스킬 최우선 (생존)
|
||||
if (hpRatio < 0.3) {
|
||||
final healSkill = _findBestHealSkill(availableSkills, currentMp);
|
||||
if (healSkill != null) return healSkill;
|
||||
}
|
||||
|
||||
// 70% 확률로 일반 공격 (스킬은 특별한 상황에서만)
|
||||
final useNormalAttack = rng.nextInt(100) < 70;
|
||||
if (useNormalAttack) return null;
|
||||
|
||||
// === 아래부터 30% 확률로 스킬 사용 ===
|
||||
|
||||
// 버프: HP > 80% & MP > 60% (매우 안전할 때만)
|
||||
// 활성 버프가 있으면 건너뜀 (중복 방지)
|
||||
if (hpRatio > 0.8 && mpRatio > 0.6) {
|
||||
final hasActiveBuff = skillSystem.activeBuffs.isNotEmpty;
|
||||
if (!hasActiveBuff) {
|
||||
final buffSkill = _findBestBuffSkill(availableSkills, currentMp);
|
||||
if (buffSkill != null) return buffSkill;
|
||||
}
|
||||
}
|
||||
|
||||
// 디버프: 몬스터 HP > 80% & 활성 디버프 없음 (전투 초반)
|
||||
if (monster.hpRatio > 0.8 && activeDebuffs.isEmpty) {
|
||||
final debuffSkill = _findBestDebuffSkill(availableSkills, currentMp);
|
||||
if (debuffSkill != null) return debuffSkill;
|
||||
}
|
||||
|
||||
// DOT: 몬스터 HP > 60% & 활성 DOT 없음 (장기전 유리)
|
||||
if (monster.hpRatio > 0.6 && activeDoTs.isEmpty) {
|
||||
final dotSkill = _findBestDotSkill(availableSkills, currentMp);
|
||||
if (dotSkill != null) return dotSkill;
|
||||
}
|
||||
|
||||
// 보스전 판단 (몬스터 레벨 20 이상 & HP 50% 이상)
|
||||
final isBossFight = monster.level >= 20 && monster.hpRatio > 0.5;
|
||||
|
||||
if (isBossFight) {
|
||||
// 가장 강력한 공격 스킬
|
||||
return _findStrongestAttackSkill(availableSkills);
|
||||
}
|
||||
|
||||
// 일반 전투 → MP 효율 좋은 공격 스킬
|
||||
return _findEfficientAttackSkill(availableSkills);
|
||||
}
|
||||
|
||||
/// 가장 좋은 DOT 스킬 찾기
|
||||
///
|
||||
/// 예상 총 데미지 (틱 × 데미지) 기준으로 선택
|
||||
Skill? _findBestDotSkill(List<Skill> skills, int currentMp) {
|
||||
final dotSkills = skills
|
||||
.where((s) => s.isDot && s.mpCost <= currentMp)
|
||||
.toList();
|
||||
|
||||
if (dotSkills.isEmpty) return null;
|
||||
|
||||
// 예상 총 데미지 기준 정렬
|
||||
dotSkills.sort((a, b) {
|
||||
final aTotal =
|
||||
(a.baseDotDamage ?? 0) *
|
||||
((a.baseDotDurationMs ?? 0) ~/ (a.baseDotTickMs ?? 1000));
|
||||
final bTotal =
|
||||
(b.baseDotDamage ?? 0) *
|
||||
((b.baseDotDurationMs ?? 0) ~/ (b.baseDotTickMs ?? 1000));
|
||||
return bTotal.compareTo(aTotal);
|
||||
});
|
||||
|
||||
return dotSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 좋은 회복 스킬 찾기
|
||||
Skill? _findBestHealSkill(List<Skill> skills, int currentMp) {
|
||||
final healSkills = skills
|
||||
.where((s) => s.isHeal && s.mpCost <= currentMp)
|
||||
.toList();
|
||||
|
||||
if (healSkills.isEmpty) return null;
|
||||
|
||||
// 회복량 기준 정렬 (% 회복 > 고정 회복)
|
||||
healSkills.sort((a, b) {
|
||||
final aValue = a.healPercent * 100 + a.healAmount;
|
||||
final bValue = b.healPercent * 100 + b.healAmount;
|
||||
return bValue.compareTo(aValue);
|
||||
});
|
||||
|
||||
return healSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 강력한 공격 스킬 찾기
|
||||
Skill? _findStrongestAttackSkill(List<Skill> skills) {
|
||||
final attackSkills = skills.where((s) => s.isAttack).toList();
|
||||
if (attackSkills.isEmpty) return null;
|
||||
|
||||
attackSkills.sort(
|
||||
(a, b) => b.damageMultiplier.compareTo(a.damageMultiplier),
|
||||
final selector = SkillAutoSelector(rng: rng);
|
||||
return selector.selectAutoSkill(
|
||||
player: player,
|
||||
monster: monster,
|
||||
skillSystem: skillSystem,
|
||||
availableSkillIds: availableSkillIds,
|
||||
canUse: (skill) =>
|
||||
canUseSkill(
|
||||
skill: skill,
|
||||
currentMp: player.mpCurrent,
|
||||
skillSystem: skillSystem,
|
||||
) ==
|
||||
null,
|
||||
activeDoTs: activeDoTs,
|
||||
activeDebuffs: activeDebuffs,
|
||||
);
|
||||
return attackSkills.first;
|
||||
}
|
||||
|
||||
/// MP 효율 좋은 공격 스킬 찾기
|
||||
Skill? _findEfficientAttackSkill(List<Skill> skills) {
|
||||
final attackSkills = skills.where((s) => s.isAttack).toList();
|
||||
if (attackSkills.isEmpty) return null;
|
||||
|
||||
attackSkills.sort((a, b) => b.mpEfficiency.compareTo(a.mpEfficiency));
|
||||
return attackSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 좋은 버프 스킬 찾기
|
||||
///
|
||||
/// ATK 증가 버프 우선, 그 다음 복합 버프
|
||||
Skill? _findBestBuffSkill(List<Skill> skills, int currentMp) {
|
||||
final buffSkills = skills
|
||||
.where((s) => s.isBuff && s.mpCost <= currentMp && s.buff != null)
|
||||
.toList();
|
||||
|
||||
if (buffSkills.isEmpty) return null;
|
||||
|
||||
// ATK 증가량 기준 정렬
|
||||
buffSkills.sort((a, b) {
|
||||
final aValue =
|
||||
(a.buff?.atkModifier ?? 0) +
|
||||
(a.buff?.defModifier ?? 0) * 0.5 +
|
||||
(a.buff?.criRateModifier ?? 0) * 0.3;
|
||||
final bValue =
|
||||
(b.buff?.atkModifier ?? 0) +
|
||||
(b.buff?.defModifier ?? 0) * 0.5 +
|
||||
(b.buff?.criRateModifier ?? 0) * 0.3;
|
||||
return bValue.compareTo(aValue);
|
||||
});
|
||||
|
||||
return buffSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 좋은 디버프 스킬 찾기
|
||||
///
|
||||
/// 적 ATK 감소 디버프 우선
|
||||
Skill? _findBestDebuffSkill(List<Skill> skills, int currentMp) {
|
||||
final debuffSkills = skills
|
||||
.where((s) => s.isDebuff && s.mpCost <= currentMp && s.buff != null)
|
||||
.toList();
|
||||
|
||||
if (debuffSkills.isEmpty) return null;
|
||||
|
||||
// 디버프 효과 크기 기준 정렬 (음수 값이므로 절대값으로 비교)
|
||||
debuffSkills.sort((a, b) {
|
||||
final aValue =
|
||||
(a.buff?.atkModifier ?? 0).abs() +
|
||||
(a.buff?.defModifier ?? 0).abs() * 0.5;
|
||||
final bValue =
|
||||
(b.buff?.atkModifier ?? 0).abs() +
|
||||
(b.buff?.defModifier ?? 0).abs() * 0.5;
|
||||
return bValue.compareTo(aValue);
|
||||
});
|
||||
|
||||
return debuffSkills.first;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
259
lib/src/core/engine/task_generator.dart
Normal file
259
lib/src/core/engine/task_generator.dart
Normal file
@@ -0,0 +1,259 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/core/engine/act_progression_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.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/pq_config.dart';
|
||||
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
||||
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||
|
||||
/// 태스크 생성 서비스
|
||||
///
|
||||
/// ProgressService에서 분리된 다음 태스크 생성 로직 담당:
|
||||
/// - 시장 이동, 전환 태스크, 보스 리트라이, 몬스터 전투 생성
|
||||
class TaskGenerator {
|
||||
const TaskGenerator({required this.config});
|
||||
|
||||
final PqConfig config;
|
||||
|
||||
/// 큐가 비어있을 때 다음 태스크 생성 (원본 Dequeue 667-684줄)
|
||||
({ProgressState progress, QueueState queue}) generateNextTask(
|
||||
GameState state,
|
||||
) {
|
||||
var progress = state.progress;
|
||||
final queue = state.queue;
|
||||
final oldTaskType = progress.currentTask.type;
|
||||
|
||||
// 1. Encumbrance 초과 시 시장 이동
|
||||
if (_shouldGoToMarket(progress)) {
|
||||
return _createMarketTask(progress, queue);
|
||||
}
|
||||
|
||||
// 2. 전환 태스크 (buying/heading)
|
||||
if (_needsTransitionTask(oldTaskType)) {
|
||||
return _createTransitionTask(state, progress, queue);
|
||||
}
|
||||
|
||||
// 3. Act Boss 리트라이
|
||||
if (state.progress.pendingActCompletion) {
|
||||
return _createActBossRetryTask(state, progress, queue);
|
||||
}
|
||||
|
||||
// 4. 최종 보스 전투
|
||||
if (state.progress.finalBossState == FinalBossState.fighting &&
|
||||
!state.progress.isInBossLevelingMode) {
|
||||
if (state.progress.bossLevelingEndTime != null) {
|
||||
progress = progress.copyWith(clearBossLevelingEndTime: true);
|
||||
}
|
||||
final actProgressionService = ActProgressionService(config: config);
|
||||
return actProgressionService.startFinalBossFight(state, progress, queue);
|
||||
}
|
||||
|
||||
// 5. 일반 몬스터 전투
|
||||
return _createMonsterTask(state, progress, queue);
|
||||
}
|
||||
|
||||
/// 시장 이동 조건 확인
|
||||
bool _shouldGoToMarket(ProgressState progress) {
|
||||
return progress.encumbrance.position >= progress.encumbrance.max &&
|
||||
progress.encumbrance.max > 0;
|
||||
}
|
||||
|
||||
/// 전환 태스크 필요 여부 확인
|
||||
bool _needsTransitionTask(TaskType oldTaskType) {
|
||||
return oldTaskType != TaskType.kill &&
|
||||
oldTaskType != TaskType.neutral &&
|
||||
oldTaskType != TaskType.buying;
|
||||
}
|
||||
|
||||
/// 시장 이동 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createMarketTask(
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskHeadingToMarket(),
|
||||
4 * 1000,
|
||||
);
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.market),
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// 전환 태스크 생성 (buying 또는 heading)
|
||||
({ProgressState progress, QueueState queue}) _createTransitionTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final gold = state.inventory.gold;
|
||||
final equipPrice = state.traits.level * 50;
|
||||
|
||||
// Gold 충분 시 장비 구매
|
||||
if (gold > equipPrice) {
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskUpgradingHardware(),
|
||||
5 * 1000,
|
||||
);
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.buying,
|
||||
),
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
// Gold 부족 시 전장 이동
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskEnteringDebugZone(),
|
||||
4 * 1000,
|
||||
);
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.neutral,
|
||||
),
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// Act Boss 재도전 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createActBossRetryTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final actProgressionService = ActProgressionService(config: config);
|
||||
final actBoss = actProgressionService.createActBoss(state);
|
||||
final combatCalculator = CombatCalculator(rng: state.rng);
|
||||
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
||||
player: actBoss.playerStats,
|
||||
monster: actBoss.monsterStats,
|
||||
);
|
||||
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskDebugging(actBoss.monsterStats.name),
|
||||
durationMillis,
|
||||
);
|
||||
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.kill,
|
||||
monsterBaseName: actBoss.monsterStats.name,
|
||||
monsterPart: '*',
|
||||
monsterLevel: actBoss.monsterStats.level,
|
||||
monsterGrade: MonsterGrade.boss,
|
||||
monsterSize: getBossSizeForAct(state.progress.plotStageCount),
|
||||
),
|
||||
currentCombat: actBoss,
|
||||
);
|
||||
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// 일반 몬스터 전투 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createMonsterTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final level = state.traits.level;
|
||||
|
||||
// 퀘스트 몬스터 데이터 확인
|
||||
final questMonster = state.progress.currentQuestMonster;
|
||||
final questMonsterData = questMonster?.monsterData;
|
||||
final questLevel = questMonsterData != null
|
||||
? int.tryParse(questMonsterData.split('|').elementAtOrNull(1) ?? '') ??
|
||||
0
|
||||
: null;
|
||||
|
||||
// 몬스터 생성
|
||||
final monsterResult = pq_logic.monsterTask(
|
||||
config,
|
||||
state.rng,
|
||||
level,
|
||||
questMonsterData,
|
||||
questLevel,
|
||||
);
|
||||
|
||||
// 몬스터 레벨 조정 (밸런스)
|
||||
final actMinLevel = ActMonsterLevel.forPlotStage(
|
||||
state.progress.plotStageCount,
|
||||
);
|
||||
final baseLevel = math.max(level, actMinLevel);
|
||||
final effectiveMonsterLevel = monsterResult.level
|
||||
.clamp(math.max(1, baseLevel - 3), baseLevel + 3)
|
||||
.toInt();
|
||||
|
||||
// 전투 스탯 생성
|
||||
final playerCombatStats = CombatStats.fromStats(
|
||||
stats: state.stats,
|
||||
equipment: state.equipment,
|
||||
level: level,
|
||||
monsterLevel: effectiveMonsterLevel,
|
||||
);
|
||||
|
||||
final monsterCombatStats = MonsterCombatStats.fromLevel(
|
||||
name: monsterResult.displayName,
|
||||
level: effectiveMonsterLevel,
|
||||
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
|
||||
plotStageCount: state.progress.plotStageCount,
|
||||
);
|
||||
|
||||
// 전투 상태 및 지속시간
|
||||
final combatState = CombatState.start(
|
||||
playerStats: playerCombatStats,
|
||||
monsterStats: monsterCombatStats,
|
||||
);
|
||||
|
||||
final combatCalculator = CombatCalculator(rng: state.rng);
|
||||
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
||||
player: playerCombatStats,
|
||||
monster: monsterCombatStats,
|
||||
);
|
||||
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskDebugging(monsterResult.displayName),
|
||||
durationMillis,
|
||||
);
|
||||
|
||||
// 몬스터 사이즈 결정
|
||||
final monsterSize = getMonsterSizeForAct(
|
||||
plotStageCount: state.progress.plotStageCount,
|
||||
grade: monsterResult.grade,
|
||||
rng: state.rng,
|
||||
);
|
||||
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.kill,
|
||||
monsterBaseName: monsterResult.baseName,
|
||||
monsterPart: monsterResult.part,
|
||||
monsterLevel: effectiveMonsterLevel,
|
||||
monsterGrade: monsterResult.grade,
|
||||
monsterSize: monsterSize,
|
||||
),
|
||||
currentCombat: combatState,
|
||||
);
|
||||
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
}
|
||||
276
lib/src/core/model/cumulative_statistics.dart
Normal file
276
lib/src/core/model/cumulative_statistics.dart
Normal file
@@ -0,0 +1,276 @@
|
||||
import 'package:asciineverdie/src/core/model/session_statistics.dart';
|
||||
|
||||
/// 누적 통계 (Cumulative Statistics)
|
||||
///
|
||||
/// GameStatistics에서 분리된 모든 게임 세션의 누적 통계 모델.
|
||||
class CumulativeStatistics {
|
||||
const CumulativeStatistics({
|
||||
required this.totalPlayTimeMs,
|
||||
required this.totalMonstersKilled,
|
||||
required this.totalGoldEarned,
|
||||
required this.totalGoldSpent,
|
||||
required this.totalSkillsUsed,
|
||||
required this.totalCriticalHits,
|
||||
required this.bestCriticalStreak,
|
||||
required this.totalDamageDealt,
|
||||
required this.totalDamageTaken,
|
||||
required this.totalPotionsUsed,
|
||||
required this.totalItemsSold,
|
||||
required this.totalQuestsCompleted,
|
||||
required this.totalDeaths,
|
||||
required this.totalBossesDefeated,
|
||||
required this.totalLevelUps,
|
||||
required this.highestLevel,
|
||||
required this.highestGoldHeld,
|
||||
required this.gamesCompleted,
|
||||
required this.gamesStarted,
|
||||
});
|
||||
|
||||
/// 총 플레이 시간 (밀리초)
|
||||
final int totalPlayTimeMs;
|
||||
|
||||
/// 총 처치한 몬스터 수
|
||||
final int totalMonstersKilled;
|
||||
|
||||
/// 총 획득한 골드
|
||||
final int totalGoldEarned;
|
||||
|
||||
/// 총 소비한 골드
|
||||
final int totalGoldSpent;
|
||||
|
||||
/// 총 스킬 사용 횟수
|
||||
final int totalSkillsUsed;
|
||||
|
||||
/// 총 크리티컬 히트 횟수
|
||||
final int totalCriticalHits;
|
||||
|
||||
/// 최고 연속 크리티컬
|
||||
final int bestCriticalStreak;
|
||||
|
||||
/// 총 입힌 데미지
|
||||
final int totalDamageDealt;
|
||||
|
||||
/// 총 받은 데미지
|
||||
final int totalDamageTaken;
|
||||
|
||||
/// 총 사용한 물약 수
|
||||
final int totalPotionsUsed;
|
||||
|
||||
/// 총 판매한 아이템 수
|
||||
final int totalItemsSold;
|
||||
|
||||
/// 총 완료한 퀘스트 수
|
||||
final int totalQuestsCompleted;
|
||||
|
||||
/// 총 사망 횟수
|
||||
final int totalDeaths;
|
||||
|
||||
/// 총 처치한 보스 수
|
||||
final int totalBossesDefeated;
|
||||
|
||||
/// 총 레벨업 횟수
|
||||
final int totalLevelUps;
|
||||
|
||||
/// 최고 달성 레벨
|
||||
final int highestLevel;
|
||||
|
||||
/// 최대 보유 골드
|
||||
final int highestGoldHeld;
|
||||
|
||||
/// 클리어한 게임 수
|
||||
final int gamesCompleted;
|
||||
|
||||
/// 시작한 게임 수
|
||||
final int gamesStarted;
|
||||
|
||||
/// 총 플레이 시간 Duration
|
||||
Duration get totalPlayTime => Duration(milliseconds: totalPlayTimeMs);
|
||||
|
||||
/// 총 플레이 시간 포맷 (HH:MM:SS)
|
||||
String get formattedTotalPlayTime {
|
||||
final hours = totalPlayTime.inHours;
|
||||
final minutes = totalPlayTime.inMinutes % 60;
|
||||
final seconds = totalPlayTime.inSeconds % 60;
|
||||
return '${hours.toString().padLeft(2, '0')}:'
|
||||
'${minutes.toString().padLeft(2, '0')}:'
|
||||
'${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 평균 게임당 플레이 시간
|
||||
Duration get averagePlayTimePerGame {
|
||||
if (gamesStarted <= 0) return Duration.zero;
|
||||
return Duration(milliseconds: totalPlayTimeMs ~/ gamesStarted);
|
||||
}
|
||||
|
||||
/// 게임 완료율
|
||||
double get completionRate {
|
||||
if (gamesStarted <= 0) return 0;
|
||||
return gamesCompleted / gamesStarted;
|
||||
}
|
||||
|
||||
/// 빈 누적 통계
|
||||
factory CumulativeStatistics.empty() => const CumulativeStatistics(
|
||||
totalPlayTimeMs: 0,
|
||||
totalMonstersKilled: 0,
|
||||
totalGoldEarned: 0,
|
||||
totalGoldSpent: 0,
|
||||
totalSkillsUsed: 0,
|
||||
totalCriticalHits: 0,
|
||||
bestCriticalStreak: 0,
|
||||
totalDamageDealt: 0,
|
||||
totalDamageTaken: 0,
|
||||
totalPotionsUsed: 0,
|
||||
totalItemsSold: 0,
|
||||
totalQuestsCompleted: 0,
|
||||
totalDeaths: 0,
|
||||
totalBossesDefeated: 0,
|
||||
totalLevelUps: 0,
|
||||
highestLevel: 0,
|
||||
highestGoldHeld: 0,
|
||||
gamesCompleted: 0,
|
||||
gamesStarted: 0,
|
||||
);
|
||||
|
||||
/// 세션 통계 병합
|
||||
CumulativeStatistics mergeSession(SessionStatistics session) {
|
||||
return CumulativeStatistics(
|
||||
totalPlayTimeMs: totalPlayTimeMs + session.playTimeMs,
|
||||
totalMonstersKilled: totalMonstersKilled + session.monstersKilled,
|
||||
totalGoldEarned: totalGoldEarned + session.goldEarned,
|
||||
totalGoldSpent: totalGoldSpent + session.goldSpent,
|
||||
totalSkillsUsed: totalSkillsUsed + session.skillsUsed,
|
||||
totalCriticalHits: totalCriticalHits + session.criticalHits,
|
||||
bestCriticalStreak: session.maxCriticalStreak > bestCriticalStreak
|
||||
? session.maxCriticalStreak
|
||||
: bestCriticalStreak,
|
||||
totalDamageDealt: totalDamageDealt + session.totalDamageDealt,
|
||||
totalDamageTaken: totalDamageTaken + session.totalDamageTaken,
|
||||
totalPotionsUsed: totalPotionsUsed + session.potionsUsed,
|
||||
totalItemsSold: totalItemsSold + session.itemsSold,
|
||||
totalQuestsCompleted: totalQuestsCompleted + session.questsCompleted,
|
||||
totalDeaths: totalDeaths + session.deathCount,
|
||||
totalBossesDefeated: totalBossesDefeated + session.bossesDefeated,
|
||||
totalLevelUps: totalLevelUps + session.levelUps,
|
||||
highestLevel: highestLevel,
|
||||
highestGoldHeld: highestGoldHeld,
|
||||
gamesCompleted: gamesCompleted,
|
||||
gamesStarted: gamesStarted,
|
||||
);
|
||||
}
|
||||
|
||||
/// 최고 레벨 업데이트
|
||||
CumulativeStatistics updateHighestLevel(int level) {
|
||||
if (level <= highestLevel) return this;
|
||||
return copyWith(highestLevel: level);
|
||||
}
|
||||
|
||||
/// 최대 골드 업데이트
|
||||
CumulativeStatistics updateHighestGold(int gold) {
|
||||
if (gold <= highestGoldHeld) return this;
|
||||
return copyWith(highestGoldHeld: gold);
|
||||
}
|
||||
|
||||
/// 새 게임 시작 기록
|
||||
CumulativeStatistics recordGameStart() {
|
||||
return copyWith(gamesStarted: gamesStarted + 1);
|
||||
}
|
||||
|
||||
/// 게임 클리어 기록
|
||||
CumulativeStatistics recordGameComplete() {
|
||||
return copyWith(gamesCompleted: gamesCompleted + 1);
|
||||
}
|
||||
|
||||
CumulativeStatistics copyWith({
|
||||
int? totalPlayTimeMs,
|
||||
int? totalMonstersKilled,
|
||||
int? totalGoldEarned,
|
||||
int? totalGoldSpent,
|
||||
int? totalSkillsUsed,
|
||||
int? totalCriticalHits,
|
||||
int? bestCriticalStreak,
|
||||
int? totalDamageDealt,
|
||||
int? totalDamageTaken,
|
||||
int? totalPotionsUsed,
|
||||
int? totalItemsSold,
|
||||
int? totalQuestsCompleted,
|
||||
int? totalDeaths,
|
||||
int? totalBossesDefeated,
|
||||
int? totalLevelUps,
|
||||
int? highestLevel,
|
||||
int? highestGoldHeld,
|
||||
int? gamesCompleted,
|
||||
int? gamesStarted,
|
||||
}) {
|
||||
return CumulativeStatistics(
|
||||
totalPlayTimeMs: totalPlayTimeMs ?? this.totalPlayTimeMs,
|
||||
totalMonstersKilled: totalMonstersKilled ?? this.totalMonstersKilled,
|
||||
totalGoldEarned: totalGoldEarned ?? this.totalGoldEarned,
|
||||
totalGoldSpent: totalGoldSpent ?? this.totalGoldSpent,
|
||||
totalSkillsUsed: totalSkillsUsed ?? this.totalSkillsUsed,
|
||||
totalCriticalHits: totalCriticalHits ?? this.totalCriticalHits,
|
||||
bestCriticalStreak: bestCriticalStreak ?? this.bestCriticalStreak,
|
||||
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
|
||||
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
|
||||
totalPotionsUsed: totalPotionsUsed ?? this.totalPotionsUsed,
|
||||
totalItemsSold: totalItemsSold ?? this.totalItemsSold,
|
||||
totalQuestsCompleted: totalQuestsCompleted ?? this.totalQuestsCompleted,
|
||||
totalDeaths: totalDeaths ?? this.totalDeaths,
|
||||
totalBossesDefeated: totalBossesDefeated ?? this.totalBossesDefeated,
|
||||
totalLevelUps: totalLevelUps ?? this.totalLevelUps,
|
||||
highestLevel: highestLevel ?? this.highestLevel,
|
||||
highestGoldHeld: highestGoldHeld ?? this.highestGoldHeld,
|
||||
gamesCompleted: gamesCompleted ?? this.gamesCompleted,
|
||||
gamesStarted: gamesStarted ?? this.gamesStarted,
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON 직렬화
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'totalPlayTimeMs': totalPlayTimeMs,
|
||||
'totalMonstersKilled': totalMonstersKilled,
|
||||
'totalGoldEarned': totalGoldEarned,
|
||||
'totalGoldSpent': totalGoldSpent,
|
||||
'totalSkillsUsed': totalSkillsUsed,
|
||||
'totalCriticalHits': totalCriticalHits,
|
||||
'bestCriticalStreak': bestCriticalStreak,
|
||||
'totalDamageDealt': totalDamageDealt,
|
||||
'totalDamageTaken': totalDamageTaken,
|
||||
'totalPotionsUsed': totalPotionsUsed,
|
||||
'totalItemsSold': totalItemsSold,
|
||||
'totalQuestsCompleted': totalQuestsCompleted,
|
||||
'totalDeaths': totalDeaths,
|
||||
'totalBossesDefeated': totalBossesDefeated,
|
||||
'totalLevelUps': totalLevelUps,
|
||||
'highestLevel': highestLevel,
|
||||
'highestGoldHeld': highestGoldHeld,
|
||||
'gamesCompleted': gamesCompleted,
|
||||
'gamesStarted': gamesStarted,
|
||||
};
|
||||
}
|
||||
|
||||
/// JSON 역직렬화
|
||||
factory CumulativeStatistics.fromJson(Map<String, dynamic> json) {
|
||||
return CumulativeStatistics(
|
||||
totalPlayTimeMs: json['totalPlayTimeMs'] as int? ?? 0,
|
||||
totalMonstersKilled: json['totalMonstersKilled'] as int? ?? 0,
|
||||
totalGoldEarned: json['totalGoldEarned'] as int? ?? 0,
|
||||
totalGoldSpent: json['totalGoldSpent'] as int? ?? 0,
|
||||
totalSkillsUsed: json['totalSkillsUsed'] as int? ?? 0,
|
||||
totalCriticalHits: json['totalCriticalHits'] as int? ?? 0,
|
||||
bestCriticalStreak: json['bestCriticalStreak'] as int? ?? 0,
|
||||
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
|
||||
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
|
||||
totalPotionsUsed: json['totalPotionsUsed'] as int? ?? 0,
|
||||
totalItemsSold: json['totalItemsSold'] as int? ?? 0,
|
||||
totalQuestsCompleted: json['totalQuestsCompleted'] as int? ?? 0,
|
||||
totalDeaths: json['totalDeaths'] as int? ?? 0,
|
||||
totalBossesDefeated: json['totalBossesDefeated'] as int? ?? 0,
|
||||
totalLevelUps: json['totalLevelUps'] as int? ?? 0,
|
||||
highestLevel: json['highestLevel'] as int? ?? 0,
|
||||
highestGoldHeld: json['highestGoldHeld'] as int? ?? 0,
|
||||
gamesCompleted: json['gamesCompleted'] as int? ?? 0,
|
||||
gamesStarted: json['gamesStarted'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import 'package:asciineverdie/src/core/model/cumulative_statistics.dart';
|
||||
import 'package:asciineverdie/src/core/model/session_statistics.dart';
|
||||
|
||||
// 하위 호환성(backward compatibility)을 위한 re-export
|
||||
export 'package:asciineverdie/src/core/model/cumulative_statistics.dart';
|
||||
export 'package:asciineverdie/src/core/model/session_statistics.dart';
|
||||
|
||||
/// 게임 통계 (Game Statistics)
|
||||
///
|
||||
/// 세션 및 누적 통계를 추적하는 모델
|
||||
/// 세션 및 누적 통계를 추적하는 모델.
|
||||
/// 세부 구현은 SessionStatistics와 CumulativeStatistics로 분리됨.
|
||||
class GameStatistics {
|
||||
const GameStatistics({required this.session, required this.cumulative});
|
||||
|
||||
@@ -59,558 +67,3 @@ class GameStatistics {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 세션 통계 (Session Statistics)
|
||||
///
|
||||
/// 현재 게임 세션의 통계
|
||||
class SessionStatistics {
|
||||
const SessionStatistics({
|
||||
required this.playTimeMs,
|
||||
required this.monstersKilled,
|
||||
required this.goldEarned,
|
||||
required this.goldSpent,
|
||||
required this.skillsUsed,
|
||||
required this.criticalHits,
|
||||
required this.maxCriticalStreak,
|
||||
required this.currentCriticalStreak,
|
||||
required this.totalDamageDealt,
|
||||
required this.totalDamageTaken,
|
||||
required this.potionsUsed,
|
||||
required this.itemsSold,
|
||||
required this.questsCompleted,
|
||||
required this.deathCount,
|
||||
required this.bossesDefeated,
|
||||
required this.levelUps,
|
||||
});
|
||||
|
||||
/// 플레이 시간 (밀리초)
|
||||
final int playTimeMs;
|
||||
|
||||
/// 처치한 몬스터 수
|
||||
final int monstersKilled;
|
||||
|
||||
/// 획득한 골드 총량
|
||||
final int goldEarned;
|
||||
|
||||
/// 소비한 골드 총량
|
||||
final int goldSpent;
|
||||
|
||||
/// 사용한 스킬 횟수
|
||||
final int skillsUsed;
|
||||
|
||||
/// 크리티컬 히트 횟수
|
||||
final int criticalHits;
|
||||
|
||||
/// 최대 연속 크리티컬
|
||||
final int maxCriticalStreak;
|
||||
|
||||
/// 현재 연속 크리티컬 (내부 추적용)
|
||||
final int currentCriticalStreak;
|
||||
|
||||
/// 총 입힌 데미지
|
||||
final int totalDamageDealt;
|
||||
|
||||
/// 총 받은 데미지
|
||||
final int totalDamageTaken;
|
||||
|
||||
/// 사용한 물약 수
|
||||
final int potionsUsed;
|
||||
|
||||
/// 판매한 아이템 수
|
||||
final int itemsSold;
|
||||
|
||||
/// 완료한 퀘스트 수
|
||||
final int questsCompleted;
|
||||
|
||||
/// 사망 횟수
|
||||
final int deathCount;
|
||||
|
||||
/// 처치한 보스 수
|
||||
final int bossesDefeated;
|
||||
|
||||
/// 레벨업 횟수
|
||||
final int levelUps;
|
||||
|
||||
/// 플레이 시간 Duration
|
||||
Duration get playTime => Duration(milliseconds: playTimeMs);
|
||||
|
||||
/// 플레이 시간 포맷 (HH:MM:SS)
|
||||
String get formattedPlayTime {
|
||||
final hours = playTime.inHours;
|
||||
final minutes = playTime.inMinutes % 60;
|
||||
final seconds = playTime.inSeconds % 60;
|
||||
return '${hours.toString().padLeft(2, '0')}:'
|
||||
'${minutes.toString().padLeft(2, '0')}:'
|
||||
'${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 평균 DPS (damage per second)
|
||||
double get averageDps {
|
||||
if (playTimeMs <= 0) return 0;
|
||||
return totalDamageDealt / (playTimeMs / 1000);
|
||||
}
|
||||
|
||||
/// 킬당 평균 골드
|
||||
double get goldPerKill {
|
||||
if (monstersKilled <= 0) return 0;
|
||||
return goldEarned / monstersKilled;
|
||||
}
|
||||
|
||||
/// 크리티컬 비율
|
||||
double get criticalRate {
|
||||
if (skillsUsed <= 0) return 0;
|
||||
return criticalHits / skillsUsed;
|
||||
}
|
||||
|
||||
/// 빈 세션 통계
|
||||
factory SessionStatistics.empty() => const SessionStatistics(
|
||||
playTimeMs: 0,
|
||||
monstersKilled: 0,
|
||||
goldEarned: 0,
|
||||
goldSpent: 0,
|
||||
skillsUsed: 0,
|
||||
criticalHits: 0,
|
||||
maxCriticalStreak: 0,
|
||||
currentCriticalStreak: 0,
|
||||
totalDamageDealt: 0,
|
||||
totalDamageTaken: 0,
|
||||
potionsUsed: 0,
|
||||
itemsSold: 0,
|
||||
questsCompleted: 0,
|
||||
deathCount: 0,
|
||||
bossesDefeated: 0,
|
||||
levelUps: 0,
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 이벤트 기록 메서드
|
||||
// ============================================================================
|
||||
|
||||
/// 몬스터 처치 기록
|
||||
SessionStatistics recordKill({bool isBoss = false}) {
|
||||
return copyWith(
|
||||
monstersKilled: monstersKilled + 1,
|
||||
bossesDefeated: isBoss ? bossesDefeated + 1 : bossesDefeated,
|
||||
);
|
||||
}
|
||||
|
||||
/// 골드 획득 기록
|
||||
SessionStatistics recordGoldEarned(int amount) {
|
||||
return copyWith(goldEarned: goldEarned + amount);
|
||||
}
|
||||
|
||||
/// 골드 소비 기록
|
||||
SessionStatistics recordGoldSpent(int amount) {
|
||||
return copyWith(goldSpent: goldSpent + amount);
|
||||
}
|
||||
|
||||
/// 스킬 사용 기록
|
||||
SessionStatistics recordSkillUse({required bool isCritical}) {
|
||||
final newCriticalStreak = isCritical ? currentCriticalStreak + 1 : 0;
|
||||
final newMaxStreak = newCriticalStreak > maxCriticalStreak
|
||||
? newCriticalStreak
|
||||
: maxCriticalStreak;
|
||||
|
||||
return copyWith(
|
||||
skillsUsed: skillsUsed + 1,
|
||||
criticalHits: isCritical ? criticalHits + 1 : criticalHits,
|
||||
currentCriticalStreak: newCriticalStreak,
|
||||
maxCriticalStreak: newMaxStreak,
|
||||
);
|
||||
}
|
||||
|
||||
/// 데미지 기록
|
||||
SessionStatistics recordDamage({int dealt = 0, int taken = 0}) {
|
||||
return copyWith(
|
||||
totalDamageDealt: totalDamageDealt + dealt,
|
||||
totalDamageTaken: totalDamageTaken + taken,
|
||||
);
|
||||
}
|
||||
|
||||
/// 물약 사용 기록
|
||||
SessionStatistics recordPotionUse() {
|
||||
return copyWith(potionsUsed: potionsUsed + 1);
|
||||
}
|
||||
|
||||
/// 아이템 판매 기록
|
||||
SessionStatistics recordItemSold(int count) {
|
||||
return copyWith(itemsSold: itemsSold + count);
|
||||
}
|
||||
|
||||
/// 퀘스트 완료 기록
|
||||
SessionStatistics recordQuestComplete() {
|
||||
return copyWith(questsCompleted: questsCompleted + 1);
|
||||
}
|
||||
|
||||
/// 사망 기록
|
||||
SessionStatistics recordDeath() {
|
||||
return copyWith(deathCount: deathCount + 1);
|
||||
}
|
||||
|
||||
/// 레벨업 기록
|
||||
SessionStatistics recordLevelUp() {
|
||||
return copyWith(levelUps: levelUps + 1);
|
||||
}
|
||||
|
||||
/// 플레이 시간 업데이트
|
||||
SessionStatistics updatePlayTime(int elapsedMs) {
|
||||
return copyWith(playTimeMs: elapsedMs);
|
||||
}
|
||||
|
||||
SessionStatistics copyWith({
|
||||
int? playTimeMs,
|
||||
int? monstersKilled,
|
||||
int? goldEarned,
|
||||
int? goldSpent,
|
||||
int? skillsUsed,
|
||||
int? criticalHits,
|
||||
int? maxCriticalStreak,
|
||||
int? currentCriticalStreak,
|
||||
int? totalDamageDealt,
|
||||
int? totalDamageTaken,
|
||||
int? potionsUsed,
|
||||
int? itemsSold,
|
||||
int? questsCompleted,
|
||||
int? deathCount,
|
||||
int? bossesDefeated,
|
||||
int? levelUps,
|
||||
}) {
|
||||
return SessionStatistics(
|
||||
playTimeMs: playTimeMs ?? this.playTimeMs,
|
||||
monstersKilled: monstersKilled ?? this.monstersKilled,
|
||||
goldEarned: goldEarned ?? this.goldEarned,
|
||||
goldSpent: goldSpent ?? this.goldSpent,
|
||||
skillsUsed: skillsUsed ?? this.skillsUsed,
|
||||
criticalHits: criticalHits ?? this.criticalHits,
|
||||
maxCriticalStreak: maxCriticalStreak ?? this.maxCriticalStreak,
|
||||
currentCriticalStreak:
|
||||
currentCriticalStreak ?? this.currentCriticalStreak,
|
||||
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
|
||||
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
|
||||
potionsUsed: potionsUsed ?? this.potionsUsed,
|
||||
itemsSold: itemsSold ?? this.itemsSold,
|
||||
questsCompleted: questsCompleted ?? this.questsCompleted,
|
||||
deathCount: deathCount ?? this.deathCount,
|
||||
bossesDefeated: bossesDefeated ?? this.bossesDefeated,
|
||||
levelUps: levelUps ?? this.levelUps,
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON 직렬화
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'playTimeMs': playTimeMs,
|
||||
'monstersKilled': monstersKilled,
|
||||
'goldEarned': goldEarned,
|
||||
'goldSpent': goldSpent,
|
||||
'skillsUsed': skillsUsed,
|
||||
'criticalHits': criticalHits,
|
||||
'maxCriticalStreak': maxCriticalStreak,
|
||||
'totalDamageDealt': totalDamageDealt,
|
||||
'totalDamageTaken': totalDamageTaken,
|
||||
'potionsUsed': potionsUsed,
|
||||
'itemsSold': itemsSold,
|
||||
'questsCompleted': questsCompleted,
|
||||
'deathCount': deathCount,
|
||||
'bossesDefeated': bossesDefeated,
|
||||
'levelUps': levelUps,
|
||||
};
|
||||
}
|
||||
|
||||
/// JSON 역직렬화
|
||||
factory SessionStatistics.fromJson(Map<String, dynamic> json) {
|
||||
return SessionStatistics(
|
||||
playTimeMs: json['playTimeMs'] as int? ?? 0,
|
||||
monstersKilled: json['monstersKilled'] as int? ?? 0,
|
||||
goldEarned: json['goldEarned'] as int? ?? 0,
|
||||
goldSpent: json['goldSpent'] as int? ?? 0,
|
||||
skillsUsed: json['skillsUsed'] as int? ?? 0,
|
||||
criticalHits: json['criticalHits'] as int? ?? 0,
|
||||
maxCriticalStreak: json['maxCriticalStreak'] as int? ?? 0,
|
||||
currentCriticalStreak: 0, // 세션간 유지 안 함
|
||||
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
|
||||
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
|
||||
potionsUsed: json['potionsUsed'] as int? ?? 0,
|
||||
itemsSold: json['itemsSold'] as int? ?? 0,
|
||||
questsCompleted: json['questsCompleted'] as int? ?? 0,
|
||||
deathCount: json['deathCount'] as int? ?? 0,
|
||||
bossesDefeated: json['bossesDefeated'] as int? ?? 0,
|
||||
levelUps: json['levelUps'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 누적 통계 (Cumulative Statistics)
|
||||
///
|
||||
/// 모든 게임 세션의 누적 통계
|
||||
class CumulativeStatistics {
|
||||
const CumulativeStatistics({
|
||||
required this.totalPlayTimeMs,
|
||||
required this.totalMonstersKilled,
|
||||
required this.totalGoldEarned,
|
||||
required this.totalGoldSpent,
|
||||
required this.totalSkillsUsed,
|
||||
required this.totalCriticalHits,
|
||||
required this.bestCriticalStreak,
|
||||
required this.totalDamageDealt,
|
||||
required this.totalDamageTaken,
|
||||
required this.totalPotionsUsed,
|
||||
required this.totalItemsSold,
|
||||
required this.totalQuestsCompleted,
|
||||
required this.totalDeaths,
|
||||
required this.totalBossesDefeated,
|
||||
required this.totalLevelUps,
|
||||
required this.highestLevel,
|
||||
required this.highestGoldHeld,
|
||||
required this.gamesCompleted,
|
||||
required this.gamesStarted,
|
||||
});
|
||||
|
||||
/// 총 플레이 시간 (밀리초)
|
||||
final int totalPlayTimeMs;
|
||||
|
||||
/// 총 처치한 몬스터 수
|
||||
final int totalMonstersKilled;
|
||||
|
||||
/// 총 획득한 골드
|
||||
final int totalGoldEarned;
|
||||
|
||||
/// 총 소비한 골드
|
||||
final int totalGoldSpent;
|
||||
|
||||
/// 총 스킬 사용 횟수
|
||||
final int totalSkillsUsed;
|
||||
|
||||
/// 총 크리티컬 히트 횟수
|
||||
final int totalCriticalHits;
|
||||
|
||||
/// 최고 연속 크리티컬
|
||||
final int bestCriticalStreak;
|
||||
|
||||
/// 총 입힌 데미지
|
||||
final int totalDamageDealt;
|
||||
|
||||
/// 총 받은 데미지
|
||||
final int totalDamageTaken;
|
||||
|
||||
/// 총 사용한 물약 수
|
||||
final int totalPotionsUsed;
|
||||
|
||||
/// 총 판매한 아이템 수
|
||||
final int totalItemsSold;
|
||||
|
||||
/// 총 완료한 퀘스트 수
|
||||
final int totalQuestsCompleted;
|
||||
|
||||
/// 총 사망 횟수
|
||||
final int totalDeaths;
|
||||
|
||||
/// 총 처치한 보스 수
|
||||
final int totalBossesDefeated;
|
||||
|
||||
/// 총 레벨업 횟수
|
||||
final int totalLevelUps;
|
||||
|
||||
/// 최고 달성 레벨
|
||||
final int highestLevel;
|
||||
|
||||
/// 최대 보유 골드
|
||||
final int highestGoldHeld;
|
||||
|
||||
/// 클리어한 게임 수
|
||||
final int gamesCompleted;
|
||||
|
||||
/// 시작한 게임 수
|
||||
final int gamesStarted;
|
||||
|
||||
/// 총 플레이 시간 Duration
|
||||
Duration get totalPlayTime => Duration(milliseconds: totalPlayTimeMs);
|
||||
|
||||
/// 총 플레이 시간 포맷 (HH:MM:SS)
|
||||
String get formattedTotalPlayTime {
|
||||
final hours = totalPlayTime.inHours;
|
||||
final minutes = totalPlayTime.inMinutes % 60;
|
||||
final seconds = totalPlayTime.inSeconds % 60;
|
||||
return '${hours.toString().padLeft(2, '0')}:'
|
||||
'${minutes.toString().padLeft(2, '0')}:'
|
||||
'${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 평균 게임당 플레이 시간
|
||||
Duration get averagePlayTimePerGame {
|
||||
if (gamesStarted <= 0) return Duration.zero;
|
||||
return Duration(milliseconds: totalPlayTimeMs ~/ gamesStarted);
|
||||
}
|
||||
|
||||
/// 게임 완료율
|
||||
double get completionRate {
|
||||
if (gamesStarted <= 0) return 0;
|
||||
return gamesCompleted / gamesStarted;
|
||||
}
|
||||
|
||||
/// 빈 누적 통계
|
||||
factory CumulativeStatistics.empty() => const CumulativeStatistics(
|
||||
totalPlayTimeMs: 0,
|
||||
totalMonstersKilled: 0,
|
||||
totalGoldEarned: 0,
|
||||
totalGoldSpent: 0,
|
||||
totalSkillsUsed: 0,
|
||||
totalCriticalHits: 0,
|
||||
bestCriticalStreak: 0,
|
||||
totalDamageDealt: 0,
|
||||
totalDamageTaken: 0,
|
||||
totalPotionsUsed: 0,
|
||||
totalItemsSold: 0,
|
||||
totalQuestsCompleted: 0,
|
||||
totalDeaths: 0,
|
||||
totalBossesDefeated: 0,
|
||||
totalLevelUps: 0,
|
||||
highestLevel: 0,
|
||||
highestGoldHeld: 0,
|
||||
gamesCompleted: 0,
|
||||
gamesStarted: 0,
|
||||
);
|
||||
|
||||
/// 세션 통계 병합
|
||||
CumulativeStatistics mergeSession(SessionStatistics session) {
|
||||
return CumulativeStatistics(
|
||||
totalPlayTimeMs: totalPlayTimeMs + session.playTimeMs,
|
||||
totalMonstersKilled: totalMonstersKilled + session.monstersKilled,
|
||||
totalGoldEarned: totalGoldEarned + session.goldEarned,
|
||||
totalGoldSpent: totalGoldSpent + session.goldSpent,
|
||||
totalSkillsUsed: totalSkillsUsed + session.skillsUsed,
|
||||
totalCriticalHits: totalCriticalHits + session.criticalHits,
|
||||
bestCriticalStreak: session.maxCriticalStreak > bestCriticalStreak
|
||||
? session.maxCriticalStreak
|
||||
: bestCriticalStreak,
|
||||
totalDamageDealt: totalDamageDealt + session.totalDamageDealt,
|
||||
totalDamageTaken: totalDamageTaken + session.totalDamageTaken,
|
||||
totalPotionsUsed: totalPotionsUsed + session.potionsUsed,
|
||||
totalItemsSold: totalItemsSold + session.itemsSold,
|
||||
totalQuestsCompleted: totalQuestsCompleted + session.questsCompleted,
|
||||
totalDeaths: totalDeaths + session.deathCount,
|
||||
totalBossesDefeated: totalBossesDefeated + session.bossesDefeated,
|
||||
totalLevelUps: totalLevelUps + session.levelUps,
|
||||
highestLevel: highestLevel, // 별도 업데이트 필요
|
||||
highestGoldHeld: highestGoldHeld, // 별도 업데이트 필요
|
||||
gamesCompleted: gamesCompleted, // 별도 업데이트 필요
|
||||
gamesStarted: gamesStarted, // 별도 업데이트 필요
|
||||
);
|
||||
}
|
||||
|
||||
/// 최고 레벨 업데이트
|
||||
CumulativeStatistics updateHighestLevel(int level) {
|
||||
if (level <= highestLevel) return this;
|
||||
return copyWith(highestLevel: level);
|
||||
}
|
||||
|
||||
/// 최대 골드 업데이트
|
||||
CumulativeStatistics updateHighestGold(int gold) {
|
||||
if (gold <= highestGoldHeld) return this;
|
||||
return copyWith(highestGoldHeld: gold);
|
||||
}
|
||||
|
||||
/// 새 게임 시작 기록
|
||||
CumulativeStatistics recordGameStart() {
|
||||
return copyWith(gamesStarted: gamesStarted + 1);
|
||||
}
|
||||
|
||||
/// 게임 클리어 기록
|
||||
CumulativeStatistics recordGameComplete() {
|
||||
return copyWith(gamesCompleted: gamesCompleted + 1);
|
||||
}
|
||||
|
||||
CumulativeStatistics copyWith({
|
||||
int? totalPlayTimeMs,
|
||||
int? totalMonstersKilled,
|
||||
int? totalGoldEarned,
|
||||
int? totalGoldSpent,
|
||||
int? totalSkillsUsed,
|
||||
int? totalCriticalHits,
|
||||
int? bestCriticalStreak,
|
||||
int? totalDamageDealt,
|
||||
int? totalDamageTaken,
|
||||
int? totalPotionsUsed,
|
||||
int? totalItemsSold,
|
||||
int? totalQuestsCompleted,
|
||||
int? totalDeaths,
|
||||
int? totalBossesDefeated,
|
||||
int? totalLevelUps,
|
||||
int? highestLevel,
|
||||
int? highestGoldHeld,
|
||||
int? gamesCompleted,
|
||||
int? gamesStarted,
|
||||
}) {
|
||||
return CumulativeStatistics(
|
||||
totalPlayTimeMs: totalPlayTimeMs ?? this.totalPlayTimeMs,
|
||||
totalMonstersKilled: totalMonstersKilled ?? this.totalMonstersKilled,
|
||||
totalGoldEarned: totalGoldEarned ?? this.totalGoldEarned,
|
||||
totalGoldSpent: totalGoldSpent ?? this.totalGoldSpent,
|
||||
totalSkillsUsed: totalSkillsUsed ?? this.totalSkillsUsed,
|
||||
totalCriticalHits: totalCriticalHits ?? this.totalCriticalHits,
|
||||
bestCriticalStreak: bestCriticalStreak ?? this.bestCriticalStreak,
|
||||
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
|
||||
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
|
||||
totalPotionsUsed: totalPotionsUsed ?? this.totalPotionsUsed,
|
||||
totalItemsSold: totalItemsSold ?? this.totalItemsSold,
|
||||
totalQuestsCompleted: totalQuestsCompleted ?? this.totalQuestsCompleted,
|
||||
totalDeaths: totalDeaths ?? this.totalDeaths,
|
||||
totalBossesDefeated: totalBossesDefeated ?? this.totalBossesDefeated,
|
||||
totalLevelUps: totalLevelUps ?? this.totalLevelUps,
|
||||
highestLevel: highestLevel ?? this.highestLevel,
|
||||
highestGoldHeld: highestGoldHeld ?? this.highestGoldHeld,
|
||||
gamesCompleted: gamesCompleted ?? this.gamesCompleted,
|
||||
gamesStarted: gamesStarted ?? this.gamesStarted,
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON 직렬화
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'totalPlayTimeMs': totalPlayTimeMs,
|
||||
'totalMonstersKilled': totalMonstersKilled,
|
||||
'totalGoldEarned': totalGoldEarned,
|
||||
'totalGoldSpent': totalGoldSpent,
|
||||
'totalSkillsUsed': totalSkillsUsed,
|
||||
'totalCriticalHits': totalCriticalHits,
|
||||
'bestCriticalStreak': bestCriticalStreak,
|
||||
'totalDamageDealt': totalDamageDealt,
|
||||
'totalDamageTaken': totalDamageTaken,
|
||||
'totalPotionsUsed': totalPotionsUsed,
|
||||
'totalItemsSold': totalItemsSold,
|
||||
'totalQuestsCompleted': totalQuestsCompleted,
|
||||
'totalDeaths': totalDeaths,
|
||||
'totalBossesDefeated': totalBossesDefeated,
|
||||
'totalLevelUps': totalLevelUps,
|
||||
'highestLevel': highestLevel,
|
||||
'highestGoldHeld': highestGoldHeld,
|
||||
'gamesCompleted': gamesCompleted,
|
||||
'gamesStarted': gamesStarted,
|
||||
};
|
||||
}
|
||||
|
||||
/// JSON 역직렬화
|
||||
factory CumulativeStatistics.fromJson(Map<String, dynamic> json) {
|
||||
return CumulativeStatistics(
|
||||
totalPlayTimeMs: json['totalPlayTimeMs'] as int? ?? 0,
|
||||
totalMonstersKilled: json['totalMonstersKilled'] as int? ?? 0,
|
||||
totalGoldEarned: json['totalGoldEarned'] as int? ?? 0,
|
||||
totalGoldSpent: json['totalGoldSpent'] as int? ?? 0,
|
||||
totalSkillsUsed: json['totalSkillsUsed'] as int? ?? 0,
|
||||
totalCriticalHits: json['totalCriticalHits'] as int? ?? 0,
|
||||
bestCriticalStreak: json['bestCriticalStreak'] as int? ?? 0,
|
||||
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
|
||||
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
|
||||
totalPotionsUsed: json['totalPotionsUsed'] as int? ?? 0,
|
||||
totalItemsSold: json['totalItemsSold'] as int? ?? 0,
|
||||
totalQuestsCompleted: json['totalQuestsCompleted'] as int? ?? 0,
|
||||
totalDeaths: json['totalDeaths'] as int? ?? 0,
|
||||
totalBossesDefeated: json['totalBossesDefeated'] as int? ?? 0,
|
||||
totalLevelUps: json['totalLevelUps'] as int? ?? 0,
|
||||
highestLevel: json['highestLevel'] as int? ?? 0,
|
||||
highestGoldHeld: json['highestGoldHeld'] as int? ?? 0,
|
||||
gamesCompleted: json['gamesCompleted'] as int? ?? 0,
|
||||
gamesStarted: json['gamesStarted'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
279
lib/src/core/model/session_statistics.dart
Normal file
279
lib/src/core/model/session_statistics.dart
Normal file
@@ -0,0 +1,279 @@
|
||||
/// 세션 통계 (Session Statistics)
|
||||
///
|
||||
/// GameStatistics에서 분리된 현재 게임 세션의 통계 모델.
|
||||
class SessionStatistics {
|
||||
const SessionStatistics({
|
||||
required this.playTimeMs,
|
||||
required this.monstersKilled,
|
||||
required this.goldEarned,
|
||||
required this.goldSpent,
|
||||
required this.skillsUsed,
|
||||
required this.criticalHits,
|
||||
required this.maxCriticalStreak,
|
||||
required this.currentCriticalStreak,
|
||||
required this.totalDamageDealt,
|
||||
required this.totalDamageTaken,
|
||||
required this.potionsUsed,
|
||||
required this.itemsSold,
|
||||
required this.questsCompleted,
|
||||
required this.deathCount,
|
||||
required this.bossesDefeated,
|
||||
required this.levelUps,
|
||||
});
|
||||
|
||||
/// 플레이 시간 (밀리초)
|
||||
final int playTimeMs;
|
||||
|
||||
/// 처치한 몬스터 수
|
||||
final int monstersKilled;
|
||||
|
||||
/// 획득한 골드 총량
|
||||
final int goldEarned;
|
||||
|
||||
/// 소비한 골드 총량
|
||||
final int goldSpent;
|
||||
|
||||
/// 사용한 스킬 횟수
|
||||
final int skillsUsed;
|
||||
|
||||
/// 크리티컬 히트 횟수
|
||||
final int criticalHits;
|
||||
|
||||
/// 최대 연속 크리티컬
|
||||
final int maxCriticalStreak;
|
||||
|
||||
/// 현재 연속 크리티컬 (내부 추적용)
|
||||
final int currentCriticalStreak;
|
||||
|
||||
/// 총 입힌 데미지
|
||||
final int totalDamageDealt;
|
||||
|
||||
/// 총 받은 데미지
|
||||
final int totalDamageTaken;
|
||||
|
||||
/// 사용한 물약 수
|
||||
final int potionsUsed;
|
||||
|
||||
/// 판매한 아이템 수
|
||||
final int itemsSold;
|
||||
|
||||
/// 완료한 퀘스트 수
|
||||
final int questsCompleted;
|
||||
|
||||
/// 사망 횟수
|
||||
final int deathCount;
|
||||
|
||||
/// 처치한 보스 수
|
||||
final int bossesDefeated;
|
||||
|
||||
/// 레벨업 횟수
|
||||
final int levelUps;
|
||||
|
||||
/// 플레이 시간 Duration
|
||||
Duration get playTime => Duration(milliseconds: playTimeMs);
|
||||
|
||||
/// 플레이 시간 포맷 (HH:MM:SS)
|
||||
String get formattedPlayTime {
|
||||
final hours = playTime.inHours;
|
||||
final minutes = playTime.inMinutes % 60;
|
||||
final seconds = playTime.inSeconds % 60;
|
||||
return '${hours.toString().padLeft(2, '0')}:'
|
||||
'${minutes.toString().padLeft(2, '0')}:'
|
||||
'${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 평균 DPS (damage per second)
|
||||
double get averageDps {
|
||||
if (playTimeMs <= 0) return 0;
|
||||
return totalDamageDealt / (playTimeMs / 1000);
|
||||
}
|
||||
|
||||
/// 킬당 평균 골드
|
||||
double get goldPerKill {
|
||||
if (monstersKilled <= 0) return 0;
|
||||
return goldEarned / monstersKilled;
|
||||
}
|
||||
|
||||
/// 크리티컬 비율
|
||||
double get criticalRate {
|
||||
if (skillsUsed <= 0) return 0;
|
||||
return criticalHits / skillsUsed;
|
||||
}
|
||||
|
||||
/// 빈 세션 통계
|
||||
factory SessionStatistics.empty() => const SessionStatistics(
|
||||
playTimeMs: 0,
|
||||
monstersKilled: 0,
|
||||
goldEarned: 0,
|
||||
goldSpent: 0,
|
||||
skillsUsed: 0,
|
||||
criticalHits: 0,
|
||||
maxCriticalStreak: 0,
|
||||
currentCriticalStreak: 0,
|
||||
totalDamageDealt: 0,
|
||||
totalDamageTaken: 0,
|
||||
potionsUsed: 0,
|
||||
itemsSold: 0,
|
||||
questsCompleted: 0,
|
||||
deathCount: 0,
|
||||
bossesDefeated: 0,
|
||||
levelUps: 0,
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 이벤트 기록 메서드
|
||||
// ============================================================================
|
||||
|
||||
/// 몬스터 처치 기록
|
||||
SessionStatistics recordKill({bool isBoss = false}) {
|
||||
return copyWith(
|
||||
monstersKilled: monstersKilled + 1,
|
||||
bossesDefeated: isBoss ? bossesDefeated + 1 : bossesDefeated,
|
||||
);
|
||||
}
|
||||
|
||||
/// 골드 획득 기록
|
||||
SessionStatistics recordGoldEarned(int amount) {
|
||||
return copyWith(goldEarned: goldEarned + amount);
|
||||
}
|
||||
|
||||
/// 골드 소비 기록
|
||||
SessionStatistics recordGoldSpent(int amount) {
|
||||
return copyWith(goldSpent: goldSpent + amount);
|
||||
}
|
||||
|
||||
/// 스킬 사용 기록
|
||||
SessionStatistics recordSkillUse({required bool isCritical}) {
|
||||
final newCriticalStreak = isCritical ? currentCriticalStreak + 1 : 0;
|
||||
final newMaxStreak = newCriticalStreak > maxCriticalStreak
|
||||
? newCriticalStreak
|
||||
: maxCriticalStreak;
|
||||
|
||||
return copyWith(
|
||||
skillsUsed: skillsUsed + 1,
|
||||
criticalHits: isCritical ? criticalHits + 1 : criticalHits,
|
||||
currentCriticalStreak: newCriticalStreak,
|
||||
maxCriticalStreak: newMaxStreak,
|
||||
);
|
||||
}
|
||||
|
||||
/// 데미지 기록
|
||||
SessionStatistics recordDamage({int dealt = 0, int taken = 0}) {
|
||||
return copyWith(
|
||||
totalDamageDealt: totalDamageDealt + dealt,
|
||||
totalDamageTaken: totalDamageTaken + taken,
|
||||
);
|
||||
}
|
||||
|
||||
/// 물약 사용 기록
|
||||
SessionStatistics recordPotionUse() {
|
||||
return copyWith(potionsUsed: potionsUsed + 1);
|
||||
}
|
||||
|
||||
/// 아이템 판매 기록
|
||||
SessionStatistics recordItemSold(int count) {
|
||||
return copyWith(itemsSold: itemsSold + count);
|
||||
}
|
||||
|
||||
/// 퀘스트 완료 기록
|
||||
SessionStatistics recordQuestComplete() {
|
||||
return copyWith(questsCompleted: questsCompleted + 1);
|
||||
}
|
||||
|
||||
/// 사망 기록
|
||||
SessionStatistics recordDeath() {
|
||||
return copyWith(deathCount: deathCount + 1);
|
||||
}
|
||||
|
||||
/// 레벨업 기록
|
||||
SessionStatistics recordLevelUp() {
|
||||
return copyWith(levelUps: levelUps + 1);
|
||||
}
|
||||
|
||||
/// 플레이 시간 업데이트
|
||||
SessionStatistics updatePlayTime(int elapsedMs) {
|
||||
return copyWith(playTimeMs: elapsedMs);
|
||||
}
|
||||
|
||||
SessionStatistics copyWith({
|
||||
int? playTimeMs,
|
||||
int? monstersKilled,
|
||||
int? goldEarned,
|
||||
int? goldSpent,
|
||||
int? skillsUsed,
|
||||
int? criticalHits,
|
||||
int? maxCriticalStreak,
|
||||
int? currentCriticalStreak,
|
||||
int? totalDamageDealt,
|
||||
int? totalDamageTaken,
|
||||
int? potionsUsed,
|
||||
int? itemsSold,
|
||||
int? questsCompleted,
|
||||
int? deathCount,
|
||||
int? bossesDefeated,
|
||||
int? levelUps,
|
||||
}) {
|
||||
return SessionStatistics(
|
||||
playTimeMs: playTimeMs ?? this.playTimeMs,
|
||||
monstersKilled: monstersKilled ?? this.monstersKilled,
|
||||
goldEarned: goldEarned ?? this.goldEarned,
|
||||
goldSpent: goldSpent ?? this.goldSpent,
|
||||
skillsUsed: skillsUsed ?? this.skillsUsed,
|
||||
criticalHits: criticalHits ?? this.criticalHits,
|
||||
maxCriticalStreak: maxCriticalStreak ?? this.maxCriticalStreak,
|
||||
currentCriticalStreak:
|
||||
currentCriticalStreak ?? this.currentCriticalStreak,
|
||||
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
|
||||
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
|
||||
potionsUsed: potionsUsed ?? this.potionsUsed,
|
||||
itemsSold: itemsSold ?? this.itemsSold,
|
||||
questsCompleted: questsCompleted ?? this.questsCompleted,
|
||||
deathCount: deathCount ?? this.deathCount,
|
||||
bossesDefeated: bossesDefeated ?? this.bossesDefeated,
|
||||
levelUps: levelUps ?? this.levelUps,
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON 직렬화
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'playTimeMs': playTimeMs,
|
||||
'monstersKilled': monstersKilled,
|
||||
'goldEarned': goldEarned,
|
||||
'goldSpent': goldSpent,
|
||||
'skillsUsed': skillsUsed,
|
||||
'criticalHits': criticalHits,
|
||||
'maxCriticalStreak': maxCriticalStreak,
|
||||
'totalDamageDealt': totalDamageDealt,
|
||||
'totalDamageTaken': totalDamageTaken,
|
||||
'potionsUsed': potionsUsed,
|
||||
'itemsSold': itemsSold,
|
||||
'questsCompleted': questsCompleted,
|
||||
'deathCount': deathCount,
|
||||
'bossesDefeated': bossesDefeated,
|
||||
'levelUps': levelUps,
|
||||
};
|
||||
}
|
||||
|
||||
/// JSON 역직렬화
|
||||
factory SessionStatistics.fromJson(Map<String, dynamic> json) {
|
||||
return SessionStatistics(
|
||||
playTimeMs: json['playTimeMs'] as int? ?? 0,
|
||||
monstersKilled: json['monstersKilled'] as int? ?? 0,
|
||||
goldEarned: json['goldEarned'] as int? ?? 0,
|
||||
goldSpent: json['goldSpent'] as int? ?? 0,
|
||||
skillsUsed: json['skillsUsed'] as int? ?? 0,
|
||||
criticalHits: json['criticalHits'] as int? ?? 0,
|
||||
maxCriticalStreak: json['maxCriticalStreak'] as int? ?? 0,
|
||||
currentCriticalStreak: 0, // 세션간 유지 안 함
|
||||
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
|
||||
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
|
||||
potionsUsed: json['potionsUsed'] as int? ?? 0,
|
||||
itemsSold: json['itemsSold'] as int? ?? 0,
|
||||
questsCompleted: json['questsCompleted'] as int? ?? 0,
|
||||
deathCount: json['deathCount'] as int? ?? 0,
|
||||
bossesDefeated: json['bossesDefeated'] as int? ?? 0,
|
||||
levelUps: json['levelUps'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:asciineverdie/src/core/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
||||
|
||||
/// 태스크 타입 (원본 fTask.Caption 값들에 대응)
|
||||
|
||||
@@ -2,23 +2,21 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/arena_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.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/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/race_character_frames.dart';
|
||||
import 'package:asciineverdie/src/features/arena/widgets/arena_combat_log.dart';
|
||||
import 'package:asciineverdie/src/features/arena/widgets/arena_hp_bar.dart';
|
||||
import 'package:asciineverdie/src/features/arena/widgets/arena_result_panel.dart';
|
||||
import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
// 임시 문자열 (추후 l10n으로 이동)
|
||||
const _battleTitle = 'ARENA BATTLE';
|
||||
const _hpLabel = 'HP';
|
||||
|
||||
/// 아레나 전투 화면
|
||||
///
|
||||
/// ASCII 애니메이션 기반 턴제 전투 표시
|
||||
@@ -438,7 +436,7 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
||||
backgroundColor: RetroColors.backgroundOf(context),
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_battleTitle,
|
||||
L10n.of(context).arenaBattleTitle,
|
||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15),
|
||||
),
|
||||
centerTitle: true,
|
||||
@@ -451,7 +449,18 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
||||
// 턴 표시
|
||||
_buildTurnIndicator(),
|
||||
// HP 바 (레트로 세그먼트 스타일)
|
||||
_buildRetroHpBars(),
|
||||
ArenaHpBars(
|
||||
challengerName: widget.match.challenger.characterName,
|
||||
challengerHp: _challengerHp,
|
||||
challengerHpMax: _challengerHpMax,
|
||||
challengerFlashAnimation: _challengerFlashAnimation,
|
||||
challengerHpChange: _challengerHpChange,
|
||||
opponentName: widget.match.opponent.characterName,
|
||||
opponentHp: _opponentHp,
|
||||
opponentHpMax: _opponentHpMax,
|
||||
opponentFlashAnimation: _opponentFlashAnimation,
|
||||
opponentHpChange: _opponentHpChange,
|
||||
),
|
||||
// 전투 이벤트 아이콘 (HP 바와 애니메이션 사이)
|
||||
_buildCombatEventIcons(),
|
||||
// ASCII 애니메이션 (전투 중 / 종료 분기)
|
||||
@@ -649,232 +658,6 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
||||
);
|
||||
}
|
||||
|
||||
/// 레트로 스타일 HP 바 (좌우 대칭)
|
||||
Widget _buildRetroHpBars() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.panelBgOf(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: RetroColors.borderOf(context), width: 2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 도전자 HP (좌측, 파란색)
|
||||
Expanded(
|
||||
child: _buildRetroHpBar(
|
||||
name: widget.match.challenger.characterName,
|
||||
hp: _challengerHp,
|
||||
hpMax: _challengerHpMax,
|
||||
fillColor: RetroColors.mpBlue,
|
||||
accentColor: Colors.blue,
|
||||
flashAnimation: _challengerFlashAnimation,
|
||||
hpChange: _challengerHpChange,
|
||||
isReversed: false,
|
||||
),
|
||||
),
|
||||
// VS 구분자
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'VS',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.goldOf(context),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 상대 HP (우측, 빨간색)
|
||||
Expanded(
|
||||
child: _buildRetroHpBar(
|
||||
name: widget.match.opponent.characterName,
|
||||
hp: _opponentHp,
|
||||
hpMax: _opponentHpMax,
|
||||
fillColor: RetroColors.hpRed,
|
||||
accentColor: Colors.red,
|
||||
flashAnimation: _opponentFlashAnimation,
|
||||
hpChange: _opponentHpChange,
|
||||
isReversed: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 레트로 세그먼트 HP 바
|
||||
Widget _buildRetroHpBar({
|
||||
required String name,
|
||||
required int hp,
|
||||
required int hpMax,
|
||||
required Color fillColor,
|
||||
required Color accentColor,
|
||||
required Animation<double> flashAnimation,
|
||||
required int hpChange,
|
||||
required bool isReversed,
|
||||
}) {
|
||||
final hpRatio = hpMax > 0 ? hp / hpMax : 0.0;
|
||||
final isLow = hpRatio < 0.2 && hpRatio > 0;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: flashAnimation,
|
||||
builder: (context, child) {
|
||||
// 플래시 색상 (데미지=빨강)
|
||||
final isDamage = hpChange < 0;
|
||||
final flashColor = isDamage
|
||||
? RetroColors.hpRed.withValues(alpha: flashAnimation.value * 0.4)
|
||||
: RetroColors.expGreen.withValues(
|
||||
alpha: flashAnimation.value * 0.4,
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: flashAnimation.value > 0.1
|
||||
? flashColor
|
||||
: accentColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: accentColor, width: 2),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: isReversed
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 이름
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// HP 세그먼트 바
|
||||
_buildSegmentBar(
|
||||
ratio: hpRatio,
|
||||
fillColor: fillColor,
|
||||
isLow: isLow,
|
||||
isReversed: isReversed,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// HP 수치
|
||||
Row(
|
||||
mainAxisAlignment: isReversed
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_hpLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
color: accentColor.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$hp/$hpMax',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
color: isLow ? RetroColors.hpRed : fillColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 플로팅 데미지 텍스트
|
||||
if (hpChange != 0 && flashAnimation.value > 0.05)
|
||||
Positioned(
|
||||
left: isReversed ? null : 0,
|
||||
right: isReversed ? 0 : null,
|
||||
top: -12,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -12 * (1 - flashAnimation.value)),
|
||||
child: Opacity(
|
||||
opacity: flashAnimation.value,
|
||||
child: Text(
|
||||
hpChange > 0 ? '+$hpChange' : '$hpChange',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDamage
|
||||
? RetroColors.hpRed
|
||||
: RetroColors.expGreen,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black, blurRadius: 3),
|
||||
Shadow(color: Colors.black, blurRadius: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 세그먼트 바 (8-bit 스타일)
|
||||
Widget _buildSegmentBar({
|
||||
required double ratio,
|
||||
required Color fillColor,
|
||||
required bool isLow,
|
||||
required bool isReversed,
|
||||
}) {
|
||||
const segmentCount = 10;
|
||||
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
|
||||
|
||||
final segments = List.generate(segmentCount, (index) {
|
||||
final isFilled = isReversed
|
||||
? index >= segmentCount - filledSegments
|
||||
: index < filledSegments;
|
||||
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: isFilled
|
||||
? (isLow ? RetroColors.hpRed : fillColor)
|
||||
: fillColor.withValues(alpha: 0.2),
|
||||
border: Border(
|
||||
right: index < segmentCount - 1
|
||||
? BorderSide(
|
||||
color: RetroColors.borderOf(
|
||||
context,
|
||||
).withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: RetroColors.borderOf(context), width: 1),
|
||||
),
|
||||
child: Row(children: isReversed ? segments.reversed.toList() : segments),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBattleLog() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(12),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart';
|
||||
import 'package:asciineverdie/src/features/arena/arena_setup_screen.dart';
|
||||
@@ -7,12 +8,6 @@ import 'package:asciineverdie/src/features/arena/widgets/arena_rank_card.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
import 'package:asciineverdie/src/shared/widgets/retro_panel.dart';
|
||||
|
||||
// 임시 문자열 (추후 l10n으로 이동)
|
||||
const _arenaTitle = 'LOCAL ARENA';
|
||||
const _arenaSubtitle = 'SELECT YOUR FIGHTER';
|
||||
const _arenaEmpty = 'Not enough heroes';
|
||||
const _arenaEmptyHint = 'Clear the game with 2+ characters';
|
||||
|
||||
/// 로컬 아레나 메인 화면
|
||||
///
|
||||
/// 순위표 표시 및 도전하기 버튼
|
||||
@@ -68,11 +63,12 @@ class _ArenaScreenState extends State<ArenaScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
return Scaffold(
|
||||
backgroundColor: RetroColors.backgroundOf(context),
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_arenaTitle,
|
||||
l10n.arenaTitle,
|
||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15),
|
||||
),
|
||||
centerTitle: true,
|
||||
@@ -101,6 +97,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
final l10n = L10n.of(context);
|
||||
return Center(
|
||||
child: RetroPanel(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -114,7 +111,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_arenaEmpty,
|
||||
l10n.arenaEmptyTitle,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
@@ -123,7 +120,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_arenaEmptyHint,
|
||||
l10n.arenaEmptyHint,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
@@ -143,7 +140,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: RetroGoldPanel(
|
||||
title: _arenaSubtitle,
|
||||
title: L10n.of(context).arenaSelectFighter,
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: ListView.builder(
|
||||
itemCount: rankedEntries.length,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/arena_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
@@ -13,11 +14,6 @@ import 'package:asciineverdie/src/features/arena/widgets/arena_idle_preview.dart
|
||||
import 'package:asciineverdie/src/features/arena/widgets/arena_rank_card.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
// 임시 문자열 (추후 l10n으로 이동)
|
||||
const _setupTitle = 'ARENA SETUP';
|
||||
const _selectCharacter = 'SELECT YOUR FIGHTER';
|
||||
const _startBattleLabel = 'START BATTLE';
|
||||
|
||||
/// 아레나 설정 화면
|
||||
///
|
||||
/// 캐릭터 선택 및 슬롯 선택
|
||||
@@ -128,11 +124,12 @@ class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
return Scaffold(
|
||||
backgroundColor: RetroColors.backgroundOf(context),
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_setupTitle,
|
||||
l10n.arenaSetupTitle,
|
||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15),
|
||||
),
|
||||
centerTitle: true,
|
||||
@@ -153,7 +150,7 @@ class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
_selectCharacter,
|
||||
L10n.of(context).arenaSelectFighter,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
@@ -371,7 +368,7 @@ class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_startBattleLabel,
|
||||
L10n.of(context).arenaStartBattle,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/item_service.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/shared/retro_colors.dart';
|
||||
|
||||
// 임시 문자열 (추후 l10n으로 이동)
|
||||
const _myEquipmentTitle = 'MY EQUIPMENT';
|
||||
const _enemyEquipmentTitle = 'ENEMY EQUIPMENT';
|
||||
const _selectedLabel = 'SELECTED';
|
||||
const _recommendedLabel = 'BEST';
|
||||
const _weaponLockedLabel = 'LOCKED';
|
||||
|
||||
/// 좌우 대칭 장비 비교 리스트
|
||||
///
|
||||
/// 내 장비와 상대 장비를 나란히 표시하고,
|
||||
@@ -113,6 +107,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
// 내 장비 타이틀
|
||||
@@ -125,7 +120,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
||||
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Text(
|
||||
_myEquipmentTitle,
|
||||
l10n.arenaMyEquipment,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
@@ -146,7 +141,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
||||
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Text(
|
||||
_enemyEquipmentTitle,
|
||||
l10n.arenaEnemyEquipment,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
@@ -402,7 +397,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
||||
// 잠금 표시 또는 점수 변화
|
||||
if (isLocked)
|
||||
Text(
|
||||
_weaponLockedLabel,
|
||||
L10n.of(context).arenaWeaponLocked,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
@@ -441,7 +436,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
||||
children: [
|
||||
if (isRecommended) ...[
|
||||
Text(
|
||||
_recommendedLabel,
|
||||
L10n.of(context).arenaRecommended,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
@@ -471,21 +466,22 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
||||
EquipmentItem? enemyItem,
|
||||
int scoreDiff,
|
||||
) {
|
||||
final l10n = L10n.of(context);
|
||||
final Color resultColor;
|
||||
final String resultText;
|
||||
final IconData resultIcon;
|
||||
|
||||
if (scoreDiff > 0) {
|
||||
resultColor = Colors.green;
|
||||
resultText = 'You will GAIN +$scoreDiff';
|
||||
resultText = l10n.arenaScoreGain(scoreDiff);
|
||||
resultIcon = Icons.arrow_upward;
|
||||
} else if (scoreDiff < 0) {
|
||||
resultColor = Colors.red;
|
||||
resultText = 'You will LOSE $scoreDiff';
|
||||
resultText = l10n.arenaScoreLose(scoreDiff);
|
||||
resultIcon = Icons.arrow_downward;
|
||||
} else {
|
||||
resultColor = RetroColors.textMutedOf(context);
|
||||
resultText = 'Even trade';
|
||||
resultText = l10n.arenaEvenTrade;
|
||||
resultIcon = Icons.swap_horiz;
|
||||
}
|
||||
|
||||
@@ -563,7 +559,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_selectedLabel,
|
||||
L10n.of(context).arenaSelected,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
|
||||
254
lib/src/features/arena/widgets/arena_hp_bar.dart
Normal file
254
lib/src/features/arena/widgets/arena_hp_bar.dart
Normal file
@@ -0,0 +1,254 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 아레나 전투 HP 바 (좌우 대칭 레이아웃)
|
||||
class ArenaHpBars extends StatelessWidget {
|
||||
const ArenaHpBars({
|
||||
super.key,
|
||||
required this.challengerName,
|
||||
required this.challengerHp,
|
||||
required this.challengerHpMax,
|
||||
required this.challengerFlashAnimation,
|
||||
required this.challengerHpChange,
|
||||
required this.opponentName,
|
||||
required this.opponentHp,
|
||||
required this.opponentHpMax,
|
||||
required this.opponentFlashAnimation,
|
||||
required this.opponentHpChange,
|
||||
});
|
||||
|
||||
final String challengerName;
|
||||
final int challengerHp;
|
||||
final int challengerHpMax;
|
||||
final Animation<double> challengerFlashAnimation;
|
||||
final int challengerHpChange;
|
||||
|
||||
final String opponentName;
|
||||
final int opponentHp;
|
||||
final int opponentHpMax;
|
||||
final Animation<double> opponentFlashAnimation;
|
||||
final int opponentHpChange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.panelBgOf(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: RetroColors.borderOf(context), width: 2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ArenaHpBar(
|
||||
name: challengerName,
|
||||
hp: challengerHp,
|
||||
hpMax: challengerHpMax,
|
||||
fillColor: RetroColors.mpBlue,
|
||||
accentColor: Colors.blue,
|
||||
flashAnimation: challengerFlashAnimation,
|
||||
hpChange: challengerHpChange,
|
||||
isReversed: false,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'VS',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.goldOf(context),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _ArenaHpBar(
|
||||
name: opponentName,
|
||||
hp: opponentHp,
|
||||
hpMax: opponentHpMax,
|
||||
fillColor: RetroColors.hpRed,
|
||||
accentColor: Colors.red,
|
||||
flashAnimation: opponentFlashAnimation,
|
||||
hpChange: opponentHpChange,
|
||||
isReversed: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 레트로 세그먼트 HP 바 (개별)
|
||||
class _ArenaHpBar extends StatelessWidget {
|
||||
const _ArenaHpBar({
|
||||
required this.name,
|
||||
required this.hp,
|
||||
required this.hpMax,
|
||||
required this.fillColor,
|
||||
required this.accentColor,
|
||||
required this.flashAnimation,
|
||||
required this.hpChange,
|
||||
required this.isReversed,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final int hp;
|
||||
final int hpMax;
|
||||
final Color fillColor;
|
||||
final Color accentColor;
|
||||
final Animation<double> flashAnimation;
|
||||
final int hpChange;
|
||||
final bool isReversed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hpRatio = hpMax > 0 ? hp / hpMax : 0.0;
|
||||
final isLow = hpRatio < 0.2 && hpRatio > 0;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: flashAnimation,
|
||||
builder: (context, child) {
|
||||
final isDamage = hpChange < 0;
|
||||
final flashColor = isDamage
|
||||
? RetroColors.hpRed.withValues(alpha: flashAnimation.value * 0.4)
|
||||
: RetroColors.expGreen.withValues(
|
||||
alpha: flashAnimation.value * 0.4,
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: flashAnimation.value > 0.1
|
||||
? flashColor
|
||||
: accentColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: accentColor, width: 2),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: isReversed
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildSegmentBar(context, hpRatio, isLow),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
mainAxisAlignment: isReversed
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
L10n.of(context).hpLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
color: accentColor.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$hp/$hpMax',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
color: isLow ? RetroColors.hpRed : fillColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hpChange != 0 && flashAnimation.value > 0.05)
|
||||
Positioned(
|
||||
left: isReversed ? null : 0,
|
||||
right: isReversed ? 0 : null,
|
||||
top: -12,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -12 * (1 - flashAnimation.value)),
|
||||
child: Opacity(
|
||||
opacity: flashAnimation.value,
|
||||
child: Text(
|
||||
hpChange > 0 ? '+$hpChange' : '$hpChange',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDamage
|
||||
? RetroColors.hpRed
|
||||
: RetroColors.expGreen,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black, blurRadius: 3),
|
||||
Shadow(color: Colors.black, blurRadius: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 세그먼트 바 (8-bit 스타일)
|
||||
Widget _buildSegmentBar(BuildContext context, double ratio, bool isLow) {
|
||||
const segmentCount = 10;
|
||||
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
|
||||
|
||||
final segments = List.generate(segmentCount, (index) {
|
||||
final isFilled = isReversed
|
||||
? index >= segmentCount - filledSegments
|
||||
: index < filledSegments;
|
||||
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: isFilled
|
||||
? (isLow ? RetroColors.hpRed : fillColor)
|
||||
: fillColor.withValues(alpha: 0.2),
|
||||
border: Border(
|
||||
right: index < segmentCount - 1
|
||||
? BorderSide(
|
||||
color: RetroColors.borderOf(
|
||||
context,
|
||||
).withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: RetroColors.borderOf(context), width: 1),
|
||||
),
|
||||
child: Row(children: isReversed ? segments.reversed.toList() : segments),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_widget.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
|
||||
import 'package:asciineverdie/src/core/animation/character_frames.dart';
|
||||
import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_canvas_widget.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_layer.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/character_frames.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/race_character_frames.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 아레나 idle 상태 캐릭터 미리보기 위젯
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
@@ -169,7 +170,7 @@ class ArenaRankCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'SCORE',
|
||||
L10n.of(context).arenaScore,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||
@@ -8,11 +9,6 @@ import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
// 아레나 관련 임시 문자열 (추후 l10n으로 이동)
|
||||
const _arenaVictory = 'VICTORY!';
|
||||
const _arenaDefeat = 'DEFEAT...';
|
||||
const _arenaExchange = 'EQUIPMENT EXCHANGE';
|
||||
|
||||
/// 아레나 결과 다이얼로그
|
||||
///
|
||||
/// 전투 승패 및 장비 교환 결과 표시
|
||||
@@ -65,7 +61,7 @@ class ArenaResultDialog extends StatelessWidget {
|
||||
onPressed: onClose,
|
||||
style: FilledButton.styleFrom(backgroundColor: resultColor),
|
||||
child: Text(
|
||||
l10n.buttonConfirm,
|
||||
game_l10n.buttonConfirm,
|
||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 13),
|
||||
),
|
||||
),
|
||||
@@ -74,6 +70,7 @@ class ArenaResultDialog extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildTitle(BuildContext context, bool isVictory, Color color) {
|
||||
final l10n = L10n.of(context);
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -84,7 +81,7 @@ class ArenaResultDialog extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
isVictory ? _arenaVictory : _arenaDefeat,
|
||||
isVictory ? l10n.arenaVictory : l10n.arenaDefeat,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 15,
|
||||
@@ -152,7 +149,7 @@ class ArenaResultDialog extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
isWinner ? 'WINNER' : 'LOSER',
|
||||
isWinner ? L10n.of(context).arenaWinner : L10n.of(context).arenaLoser,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
@@ -196,7 +193,7 @@ class ArenaResultDialog extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_arenaExchange,
|
||||
L10n.of(context).arenaEquipmentExchange,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
@@ -380,17 +377,17 @@ class ArenaResultDialog extends StatelessWidget {
|
||||
|
||||
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,
|
||||
EquipmentSlot.weapon => game_l10n.slotWeapon,
|
||||
EquipmentSlot.shield => game_l10n.slotShield,
|
||||
EquipmentSlot.helm => game_l10n.slotHelm,
|
||||
EquipmentSlot.hauberk => game_l10n.slotHauberk,
|
||||
EquipmentSlot.brassairts => game_l10n.slotBrassairts,
|
||||
EquipmentSlot.vambraces => game_l10n.slotVambraces,
|
||||
EquipmentSlot.gauntlets => game_l10n.slotGauntlets,
|
||||
EquipmentSlot.gambeson => game_l10n.slotGambeson,
|
||||
EquipmentSlot.cuisses => game_l10n.slotCuisses,
|
||||
EquipmentSlot.greaves => game_l10n.slotGreaves,
|
||||
EquipmentSlot.sollerets => game_l10n.slotSollerets,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||
@@ -15,12 +16,6 @@ import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
// 임시 문자열
|
||||
const _victory = 'VICTORY!';
|
||||
const _defeat = 'DEFEAT...';
|
||||
const _exchange = 'EQUIPMENT EXCHANGE';
|
||||
const _turns = 'TURNS';
|
||||
|
||||
/// 아레나 결과 패널 (인라인)
|
||||
///
|
||||
/// 전투 로그 하단에 표시되는 플로팅 결과 패널
|
||||
@@ -132,7 +127,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'${l10n.uiSaved}: $fileName',
|
||||
'${game_l10n.uiSaved}: $fileName',
|
||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 11),
|
||||
),
|
||||
backgroundColor: RetroColors.mpOf(context),
|
||||
@@ -145,7 +140,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'${l10n.uiError}: $e',
|
||||
'${game_l10n.uiError}: $e',
|
||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 11),
|
||||
),
|
||||
backgroundColor: RetroColors.hpOf(context),
|
||||
@@ -353,6 +348,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
||||
}
|
||||
|
||||
Widget _buildTitle(BuildContext context, bool isVictory, Color color) {
|
||||
final l10n = L10n.of(context);
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -363,7 +359,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
isVictory ? _victory : _defeat,
|
||||
isVictory ? l10n.arenaVictory : l10n.arenaDefeat,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
@@ -381,67 +377,26 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
||||
}
|
||||
|
||||
Widget _buildBattleSummary(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
final winner = widget.result.isVictory
|
||||
? widget.result.match.challenger.characterName
|
||||
: widget.result.match.opponent.characterName;
|
||||
final loser = widget.result.isVictory
|
||||
? widget.result.match.opponent.characterName
|
||||
: widget.result.match.challenger.characterName;
|
||||
final summaryText = l10n.arenaDefeatedIn(winner, loser, widget.turnCount);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 승자
|
||||
Text(
|
||||
winner,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
color: RetroColors.goldOf(context),
|
||||
),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
summaryText,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
color: RetroColors.textSecondaryOf(context),
|
||||
),
|
||||
Text(
|
||||
' defeated ',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
color: RetroColors.textMutedOf(context),
|
||||
),
|
||||
),
|
||||
// 패자
|
||||
Text(
|
||||
loser,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
color: RetroColors.textSecondaryOf(context),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' in ',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
color: RetroColors.textMutedOf(context),
|
||||
),
|
||||
),
|
||||
// 턴 수
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.goldOf(context).withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'${widget.turnCount} $_turns',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
color: RetroColors.goldOf(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -499,7 +454,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_exchange,
|
||||
L10n.of(context).arenaEquipmentExchange,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
@@ -639,7 +594,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
),
|
||||
child: Text(
|
||||
l10n.buttonConfirm,
|
||||
game_l10n.buttonConfirm,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
@@ -658,7 +613,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
||||
onPressed: _saveBattleLog,
|
||||
icon: const Icon(Icons.save_alt, size: 14),
|
||||
label: Text(
|
||||
l10n.uiSaveBattleLog,
|
||||
game_l10n.uiSaveBattleLog,
|
||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 11),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
@@ -681,17 +636,17 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
|
||||
|
||||
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,
|
||||
EquipmentSlot.weapon => game_l10n.slotWeapon,
|
||||
EquipmentSlot.shield => game_l10n.slotShield,
|
||||
EquipmentSlot.helm => game_l10n.slotHelm,
|
||||
EquipmentSlot.hauberk => game_l10n.slotHauberk,
|
||||
EquipmentSlot.brassairts => game_l10n.slotBrassairts,
|
||||
EquipmentSlot.vambraces => game_l10n.slotVambraces,
|
||||
EquipmentSlot.gauntlets => game_l10n.slotGauntlets,
|
||||
EquipmentSlot.gambeson => game_l10n.slotGambeson,
|
||||
EquipmentSlot.cuisses => game_l10n.slotCuisses,
|
||||
EquipmentSlot.greaves => game_l10n.slotGreaves,
|
||||
EquipmentSlot.sollerets => game_l10n.slotSollerets,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import 'package:asciineverdie/data/race_data.dart';
|
||||
import 'package:asciineverdie/src/core/animation/front_screen_animation.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/front_screen_animation.dart';
|
||||
|
||||
/// 프론트 화면용 Hero vs Glitch God ASCII 애니메이션 위젯
|
||||
///
|
||||
|
||||
@@ -5,31 +5,24 @@ import 'package:flutter/scheduler.dart' show SchedulerBinding, SchedulerPhase;
|
||||
import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey;
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
|
||||
import 'package:asciineverdie/data/story_data.dart';
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/ascii_animation_type.dart';
|
||||
import 'package:asciineverdie/src/core/engine/story_service.dart';
|
||||
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
||||
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||
import 'package:asciineverdie/src/features/game/game_session_controller.dart';
|
||||
import 'package:asciineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/cinematic_view.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/death_overlay.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/desktop_character_panel.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/desktop_equipment_panel.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/desktop_quest_panel.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/victory_overlay.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/hp_mp_bar.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/notification_overlay.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/stats_panel.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/equipment_stats_panel.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/potion_inventory_panel.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/task_progress_panel.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/active_buff_panel.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/return_rewards_dialog.dart';
|
||||
import 'package:asciineverdie/src/features/game/layouts/mobile_carousel_layout.dart';
|
||||
import 'package:asciineverdie/src/features/settings/settings_screen.dart';
|
||||
@@ -796,9 +789,21 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(flex: 2, child: _buildCharacterPanel(state)),
|
||||
Expanded(flex: 3, child: _buildEquipmentPanel(state)),
|
||||
Expanded(flex: 2, child: _buildQuestPanel(state)),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: DesktopCharacterPanel(state: state),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: DesktopEquipmentPanel(
|
||||
state: state,
|
||||
combatLogEntries: _combatLogController.entries,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: DesktopQuestPanel(state: state),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -871,667 +876,4 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// 좌측 패널: Character Sheet (Traits, Stats, Experience, Spells)
|
||||
Widget _buildCharacterPanel(GameState state) {
|
||||
final l10n = L10n.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(4),
|
||||
color: RetroColors.panelBg,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(color: RetroColors.panelBorderOuter, width: 2),
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildPanelHeader(l10n.characterSheet),
|
||||
|
||||
// Traits 목록
|
||||
_buildSectionHeader(l10n.traits),
|
||||
_buildTraitsList(state),
|
||||
|
||||
// Stats 목록 (Phase 8: 애니메이션 변화 표시)
|
||||
_buildSectionHeader(l10n.stats),
|
||||
Expanded(flex: 2, child: StatsPanel(stats: state.stats)),
|
||||
|
||||
// Phase 8: HP/MP 바 (사망 위험 시 깜빡임, 전투 중 몬스터 HP 표시)
|
||||
// 전투 중에는 전투 스탯의 HP/MP 사용, 비전투 시 기본 스탯 사용
|
||||
HpMpBar(
|
||||
hpCurrent:
|
||||
state.progress.currentCombat?.playerStats.hpCurrent ??
|
||||
state.stats.hp,
|
||||
hpMax:
|
||||
state.progress.currentCombat?.playerStats.hpMax ??
|
||||
state.stats.hpMax,
|
||||
mpCurrent:
|
||||
state.progress.currentCombat?.playerStats.mpCurrent ??
|
||||
state.stats.mp,
|
||||
mpMax:
|
||||
state.progress.currentCombat?.playerStats.mpMax ??
|
||||
state.stats.mpMax,
|
||||
// 전투 중일 때 몬스터 HP 정보 전달
|
||||
monsterHpCurrent:
|
||||
state.progress.currentCombat?.monsterStats.hpCurrent,
|
||||
monsterHpMax: state.progress.currentCombat?.monsterStats.hpMax,
|
||||
monsterName: state.progress.currentCombat?.monsterStats.name,
|
||||
monsterLevel: state.progress.currentCombat?.monsterStats.level,
|
||||
),
|
||||
|
||||
// Experience 바
|
||||
_buildSectionHeader(l10n.experience),
|
||||
_buildProgressBar(
|
||||
state.progress.exp.position,
|
||||
state.progress.exp.max,
|
||||
Colors.blue,
|
||||
tooltip:
|
||||
'${state.progress.exp.position} / ${state.progress.exp.max}',
|
||||
),
|
||||
|
||||
// 스킬 (Skills - SpellBook 기반)
|
||||
_buildSectionHeader(l10n.spellBook),
|
||||
Expanded(flex: 3, child: _buildSkillsList(state)),
|
||||
|
||||
// 활성 버프 (Active Buffs)
|
||||
_buildSectionHeader(game_l10n.uiBuffs),
|
||||
Expanded(
|
||||
child: ActiveBuffPanel(
|
||||
activeBuffs: state.skillSystem.activeBuffs,
|
||||
currentMs: state.skillSystem.elapsedMs,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 중앙 패널: Equipment/Inventory
|
||||
Widget _buildEquipmentPanel(GameState state) {
|
||||
final l10n = L10n.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(4),
|
||||
color: RetroColors.panelBg,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(color: RetroColors.panelBorderOuter, width: 2),
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildPanelHeader(l10n.equipment),
|
||||
|
||||
// Equipment 목록 (확장 가능 스탯 패널)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: EquipmentStatsPanel(equipment: state.equipment),
|
||||
),
|
||||
|
||||
// Inventory
|
||||
_buildPanelHeader(l10n.inventory),
|
||||
Expanded(child: _buildInventoryList(state)),
|
||||
|
||||
// Potions (물약 인벤토리)
|
||||
_buildSectionHeader(game_l10n.uiPotions),
|
||||
Expanded(
|
||||
child: PotionInventoryPanel(inventory: state.potionInventory),
|
||||
),
|
||||
|
||||
// Encumbrance 바
|
||||
_buildSectionHeader(l10n.encumbrance),
|
||||
_buildProgressBar(
|
||||
state.progress.encumbrance.position,
|
||||
state.progress.encumbrance.max,
|
||||
Colors.orange,
|
||||
),
|
||||
|
||||
// Phase 8: 전투 로그 (Combat Log)
|
||||
_buildPanelHeader(l10n.combatLog),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: CombatLog(entries: _combatLogController.entries),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 우측 패널: Plot/Quest
|
||||
Widget _buildQuestPanel(GameState state) {
|
||||
final l10n = L10n.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(4),
|
||||
color: RetroColors.panelBg,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(color: RetroColors.panelBorderOuter, width: 2),
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildPanelHeader(l10n.plotDevelopment),
|
||||
|
||||
// Plot 목록
|
||||
Expanded(child: _buildPlotList(state)),
|
||||
|
||||
// Plot 바
|
||||
_buildProgressBar(
|
||||
state.progress.plot.position,
|
||||
state.progress.plot.max,
|
||||
Colors.purple,
|
||||
tooltip: state.progress.plot.max > 0
|
||||
? '${pq_logic.roughTime(state.progress.plot.max - state.progress.plot.position)} remaining'
|
||||
: null,
|
||||
),
|
||||
|
||||
_buildPanelHeader(l10n.quests),
|
||||
|
||||
// Quest 목록
|
||||
Expanded(child: _buildQuestList(state)),
|
||||
|
||||
// Quest 바
|
||||
_buildProgressBar(
|
||||
state.progress.quest.position,
|
||||
state.progress.quest.max,
|
||||
Colors.green,
|
||||
tooltip: state.progress.quest.max > 0
|
||||
? l10n.percentComplete(
|
||||
100 *
|
||||
state.progress.quest.position ~/
|
||||
state.progress.quest.max,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPanelHeader(String title) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: const BoxDecoration(
|
||||
color: RetroColors.darkBrown,
|
||||
border: Border(bottom: BorderSide(color: RetroColors.gold, width: 2)),
|
||||
),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 레트로 스타일 세그먼트 프로그레스 바
|
||||
Widget _buildProgressBar(
|
||||
int position,
|
||||
int max,
|
||||
Color color, {
|
||||
String? tooltip,
|
||||
}) {
|
||||
final progress = max > 0 ? (position / max).clamp(0.0, 1.0) : 0.0;
|
||||
const segmentCount = 20;
|
||||
final filledSegments = (progress * segmentCount).round();
|
||||
|
||||
final bar = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Container(
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
border: Border.all(color: RetroColors.panelBorderOuter, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
children: List.generate(segmentCount, (index) {
|
||||
final isFilled = index < filledSegments;
|
||||
return Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isFilled ? color : color.withValues(alpha: 0.1),
|
||||
border: Border(
|
||||
right: index < segmentCount - 1
|
||||
? BorderSide(
|
||||
color: RetroColors.panelBorderOuter.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
width: 1,
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (tooltip != null && tooltip.isNotEmpty) {
|
||||
return Tooltip(message: tooltip, child: bar);
|
||||
}
|
||||
return bar;
|
||||
}
|
||||
|
||||
Widget _buildTraitsList(GameState state) {
|
||||
final l10n = L10n.of(context);
|
||||
final traits = [
|
||||
(l10n.traitName, state.traits.name),
|
||||
(l10n.traitRace, GameDataL10n.getRaceName(context, state.traits.race)),
|
||||
(l10n.traitClass, GameDataL10n.getKlassName(context, state.traits.klass)),
|
||||
(l10n.traitLevel, '${state.traits.level}'),
|
||||
];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Column(
|
||||
children: traits.map((t) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 50,
|
||||
child: Text(
|
||||
t.$1.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
t.$2,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.textLight,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 통합 스킬 목록 (SkillBook 기반)
|
||||
///
|
||||
/// 스킬 이름, 랭크, 스킬 타입, 쿨타임 표시
|
||||
Widget _buildSkillsList(GameState state) {
|
||||
if (state.skillBook.skills.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
L10n.of(context).noSpellsYet,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: state.skillBook.skills.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final skillEntry = state.skillBook.skills[index];
|
||||
final skill = SkillData.getSkillBySpellName(skillEntry.name);
|
||||
final skillName = GameDataL10n.getSpellName(context, skillEntry.name);
|
||||
|
||||
// 쿨타임 상태 확인
|
||||
final skillState = skill != null
|
||||
? state.skillSystem.getSkillState(skill.id)
|
||||
: null;
|
||||
final isOnCooldown =
|
||||
skillState != null &&
|
||||
!skillState.isReady(state.skillSystem.elapsedMs, skill!.cooldownMs);
|
||||
|
||||
return _SkillRow(
|
||||
skillName: skillName,
|
||||
rank: skillEntry.rank,
|
||||
skill: skill,
|
||||
isOnCooldown: isOnCooldown,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInventoryList(GameState state) {
|
||||
final l10n = L10n.of(context);
|
||||
if (state.inventory.items.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
l10n.goldAmount(state.inventory.gold),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: state.inventory.items.length + 1, // +1 for gold
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.monetization_on,
|
||||
size: 10,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.gold.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${state.inventory.gold}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final item = state.inventory.items[index - 1];
|
||||
// 아이템 이름 번역
|
||||
final translatedName = GameDataL10n.translateItemString(
|
||||
context,
|
||||
item.name,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
translatedName,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.textLight,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${item.count}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.cream,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlotList(GameState state) {
|
||||
// 플롯 단계를 표시 (Act I, Act II, ...)
|
||||
final l10n = L10n.of(context);
|
||||
final plotCount = state.progress.plotStageCount;
|
||||
if (plotCount == 0) {
|
||||
return Center(
|
||||
child: Text(
|
||||
l10n.prologue.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: plotCount,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final isCompleted = index < plotCount - 1;
|
||||
final isCurrent = index == plotCount - 1;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isCompleted
|
||||
? Icons.check_box
|
||||
: (isCurrent
|
||||
? Icons.arrow_right
|
||||
: Icons.check_box_outline_blank),
|
||||
size: 12,
|
||||
color: isCompleted
|
||||
? RetroColors.expGreen
|
||||
: (isCurrent ? RetroColors.gold : RetroColors.textDisabled),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
index == 0 ? l10n.prologue : l10n.actNumber(_toRoman(index)),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: isCompleted
|
||||
? RetroColors.textDisabled
|
||||
: (isCurrent
|
||||
? RetroColors.gold
|
||||
: RetroColors.textLight),
|
||||
decoration: isCompleted
|
||||
? TextDecoration.lineThrough
|
||||
: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuestList(GameState state) {
|
||||
final l10n = L10n.of(context);
|
||||
final questHistory = state.progress.questHistory;
|
||||
|
||||
if (questHistory.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
l10n.noActiveQuests.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 원본처럼 퀘스트 히스토리를 리스트로 표시
|
||||
// 완료된 퀘스트는 체크박스, 현재 퀘스트는 화살표
|
||||
return ListView.builder(
|
||||
itemCount: questHistory.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final quest = questHistory[index];
|
||||
final isCurrentQuest =
|
||||
index == questHistory.length - 1 && !quest.isComplete;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isCurrentQuest
|
||||
? Icons.arrow_right
|
||||
: (quest.isComplete
|
||||
? Icons.check_box
|
||||
: Icons.check_box_outline_blank),
|
||||
size: 12,
|
||||
color: isCurrentQuest
|
||||
? RetroColors.gold
|
||||
: (quest.isComplete
|
||||
? RetroColors.expGreen
|
||||
: RetroColors.textDisabled),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
quest.caption,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: isCurrentQuest
|
||||
? RetroColors.gold
|
||||
: (quest.isComplete
|
||||
? RetroColors.textDisabled
|
||||
: RetroColors.textLight),
|
||||
decoration: quest.isComplete
|
||||
? TextDecoration.lineThrough
|
||||
: TextDecoration.none,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 로마 숫자 변환 (간단 버전)
|
||||
String _toRoman(int number) {
|
||||
const romanNumerals = [
|
||||
(1000, 'M'),
|
||||
(900, 'CM'),
|
||||
(500, 'D'),
|
||||
(400, 'CD'),
|
||||
(100, 'C'),
|
||||
(90, 'XC'),
|
||||
(50, 'L'),
|
||||
(40, 'XL'),
|
||||
(10, 'X'),
|
||||
(9, 'IX'),
|
||||
(5, 'V'),
|
||||
(4, 'IV'),
|
||||
(1, 'I'),
|
||||
];
|
||||
|
||||
var result = '';
|
||||
var remaining = number;
|
||||
for (final (value, numeral) in romanNumerals) {
|
||||
while (remaining >= value) {
|
||||
result += numeral;
|
||||
remaining -= value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// 스킬 행 위젯
|
||||
///
|
||||
/// 스킬 이름, 랭크, 스킬 타입 아이콘, 쿨타임 상태 표시
|
||||
class _SkillRow extends StatelessWidget {
|
||||
const _SkillRow({
|
||||
required this.skillName,
|
||||
required this.rank,
|
||||
required this.skill,
|
||||
required this.isOnCooldown,
|
||||
});
|
||||
|
||||
final String skillName;
|
||||
final String rank;
|
||||
final Skill? skill;
|
||||
final bool isOnCooldown;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
// 스킬 타입 아이콘
|
||||
_buildTypeIcon(),
|
||||
const SizedBox(width: 4),
|
||||
// 스킬 이름
|
||||
Expanded(
|
||||
child: Text(
|
||||
skillName,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: isOnCooldown ? Colors.grey : null,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// 쿨타임 표시
|
||||
if (isOnCooldown)
|
||||
const Icon(Icons.hourglass_empty, size: 10, color: Colors.orange),
|
||||
const SizedBox(width: 4),
|
||||
// 랭크
|
||||
Text(
|
||||
rank,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 스킬 타입별 아이콘
|
||||
Widget _buildTypeIcon() {
|
||||
if (skill == null) {
|
||||
return const SizedBox(width: 12);
|
||||
}
|
||||
|
||||
final (IconData icon, Color color) = switch (skill!.type) {
|
||||
SkillType.attack => (Icons.flash_on, Colors.red),
|
||||
SkillType.heal => (Icons.favorite, Colors.green),
|
||||
SkillType.buff => (Icons.arrow_upward, Colors.blue),
|
||||
SkillType.debuff => (Icons.arrow_downward, Colors.purple),
|
||||
};
|
||||
|
||||
return Icon(icon, size: 12, color: color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/ascii_animation_type.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/features/game/pages/character_sheet_page.dart';
|
||||
import 'package:asciineverdie/src/features/game/pages/combat_log_page.dart';
|
||||
@@ -15,13 +14,9 @@ import 'package:asciineverdie/src/features/game/pages/skills_page.dart';
|
||||
import 'package:asciineverdie/src/features/game/pages/story_page.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/carousel_nav_bar.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/dialogs/retro_confirm_dialog.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/dialogs/retro_select_dialog.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/dialogs/retro_sound_dialog.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/enhanced_animation_panel.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/menu/retro_menu_widgets.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/mobile_options_menu.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
|
||||
|
||||
/// 모바일 캐로셀 레이아웃
|
||||
///
|
||||
@@ -169,408 +164,39 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 현재 언어명 가져오기
|
||||
String _getCurrentLanguageName() {
|
||||
final locale = l10n.currentGameLocale;
|
||||
if (locale == 'ko') return l10n.languageKorean;
|
||||
if (locale == 'ja') return l10n.languageJapanese;
|
||||
return l10n.languageEnglish;
|
||||
}
|
||||
|
||||
/// 언어 선택 다이얼로그 표시
|
||||
void _showLanguageDialog(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => RetroSelectDialog(
|
||||
title: l10n.menuLanguage.toUpperCase(),
|
||||
children: [
|
||||
_buildLanguageOption(context, 'en', l10n.languageEnglish, '🇺🇸'),
|
||||
_buildLanguageOption(context, 'ko', l10n.languageKorean, '🇰🇷'),
|
||||
_buildLanguageOption(context, 'ja', l10n.languageJapanese, '🇯🇵'),
|
||||
],
|
||||
void _openOptionsMenu(BuildContext context) {
|
||||
showMobileOptionsMenu(
|
||||
context,
|
||||
MobileOptionsConfig(
|
||||
isPaused: widget.isPaused,
|
||||
speedMultiplier: widget.speedMultiplier,
|
||||
bgmVolume: widget.bgmVolume,
|
||||
sfxVolume: widget.sfxVolume,
|
||||
cheatsEnabled: widget.cheatsEnabled,
|
||||
isPaidUser: widget.isPaidUser,
|
||||
isSpeedBoostActive: widget.isSpeedBoostActive,
|
||||
adSpeedMultiplier: widget.adSpeedMultiplier,
|
||||
notificationService: widget.notificationService,
|
||||
onPauseToggle: widget.onPauseToggle,
|
||||
onSpeedCycle: widget.onSpeedCycle,
|
||||
onSave: widget.onSave,
|
||||
onExit: widget.onExit,
|
||||
onLanguageChange: widget.onLanguageChange,
|
||||
onDeleteSaveAndNewGame: widget.onDeleteSaveAndNewGame,
|
||||
onBgmVolumeChange: widget.onBgmVolumeChange,
|
||||
onSfxVolumeChange: widget.onSfxVolumeChange,
|
||||
onShowStatistics: widget.onShowStatistics,
|
||||
onShowHelp: widget.onShowHelp,
|
||||
onCheatTask: widget.onCheatTask,
|
||||
onCheatQuest: widget.onCheatQuest,
|
||||
onCheatPlot: widget.onCheatPlot,
|
||||
onCreateTestCharacter: widget.onCreateTestCharacter,
|
||||
onSpeedBoostActivate: widget.onSpeedBoostActivate,
|
||||
onSetSpeed: widget.onSetSpeed,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLanguageOption(
|
||||
BuildContext context,
|
||||
String locale,
|
||||
String label,
|
||||
String flag,
|
||||
) {
|
||||
final isSelected = l10n.currentGameLocale == locale;
|
||||
return RetroOptionItem(
|
||||
label: label.toUpperCase(),
|
||||
prefix: flag,
|
||||
isSelected: isSelected,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onLanguageChange(locale);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 사운드 상태 텍스트 가져오기
|
||||
String _getSoundStatus() {
|
||||
final bgmPercent = (widget.bgmVolume * 100).round();
|
||||
final sfxPercent = (widget.sfxVolume * 100).round();
|
||||
if (bgmPercent == 0 && sfxPercent == 0) {
|
||||
return l10n.uiSoundOff;
|
||||
}
|
||||
return 'BGM $bgmPercent% / SFX $sfxPercent%';
|
||||
}
|
||||
|
||||
/// 사운드 설정 다이얼로그 표시
|
||||
void _showSoundDialog(BuildContext context) {
|
||||
var bgmVolume = widget.bgmVolume;
|
||||
var sfxVolume = widget.sfxVolume;
|
||||
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => RetroSoundDialog(
|
||||
bgmVolume: bgmVolume,
|
||||
sfxVolume: sfxVolume,
|
||||
onBgmChanged: (double value) {
|
||||
setDialogState(() => bgmVolume = value);
|
||||
widget.onBgmVolumeChange?.call(value);
|
||||
},
|
||||
onSfxChanged: (double value) {
|
||||
setDialogState(() => sfxVolume = value);
|
||||
widget.onSfxVolumeChange?.call(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 세이브 삭제 확인 다이얼로그 표시
|
||||
void _showDeleteConfirmDialog(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => RetroConfirmDialog(
|
||||
title: l10n.confirmDeleteTitle.toUpperCase(),
|
||||
message: l10n.confirmDeleteMessage,
|
||||
confirmText: l10n.buttonConfirm.toUpperCase(),
|
||||
cancelText: l10n.buttonCancel.toUpperCase(),
|
||||
onConfirm: () {
|
||||
Navigator.pop(context);
|
||||
widget.onDeleteSaveAndNewGame();
|
||||
},
|
||||
onCancel: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 테스트 캐릭터 생성 확인 다이얼로그
|
||||
Future<void> _showTestCharacterDialog(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => RetroConfirmDialog(
|
||||
title: L10n.of(context).debugCreateTestCharacterTitle,
|
||||
message: L10n.of(context).debugCreateTestCharacterMessage,
|
||||
confirmText: L10n.of(context).createButton,
|
||||
cancelText: L10n.of(context).cancel.toUpperCase(),
|
||||
onConfirm: () => Navigator.of(context).pop(true),
|
||||
onCancel: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
await widget.onCreateTestCharacter?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// 옵션 메뉴 표시
|
||||
void _showOptionsMenu(BuildContext context) {
|
||||
final localizations = L10n.of(context);
|
||||
final background = RetroColors.backgroundOf(context);
|
||||
final gold = RetroColors.goldOf(context);
|
||||
final border = RetroColors.borderOf(context);
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.75,
|
||||
),
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
border: Border.all(color: border, width: 2),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 핸들 바
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Container(width: 60, height: 4, color: border),
|
||||
),
|
||||
// 헤더
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.panelBgOf(context),
|
||||
border: Border(bottom: BorderSide(color: gold, width: 2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.settings, color: gold, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
L10n.of(context).optionsTitle,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: gold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
RetroIconButton(
|
||||
icon: Icons.close,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
size: 28,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 메뉴 목록
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// === 게임 제어 ===
|
||||
RetroMenuSection(title: L10n.of(context).controlSection),
|
||||
const SizedBox(height: 8),
|
||||
// 일시정지/재개
|
||||
RetroMenuItem(
|
||||
icon: widget.isPaused ? Icons.play_arrow : Icons.pause,
|
||||
iconColor: widget.isPaused
|
||||
? RetroColors.expOf(context)
|
||||
: RetroColors.warningOf(context),
|
||||
label: widget.isPaused
|
||||
? l10n.menuResume.toUpperCase()
|
||||
: l10n.menuPause.toUpperCase(),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onPauseToggle();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 속도 조절
|
||||
RetroMenuItem(
|
||||
icon: Icons.speed,
|
||||
iconColor: gold,
|
||||
label: l10n.menuSpeed.toUpperCase(),
|
||||
trailing: _buildRetroSpeedSelector(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// === 정보 ===
|
||||
RetroMenuSection(title: L10n.of(context).infoSection),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.onShowStatistics != null)
|
||||
RetroMenuItem(
|
||||
icon: Icons.bar_chart,
|
||||
iconColor: RetroColors.mpOf(context),
|
||||
label: l10n.uiStatistics.toUpperCase(),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onShowStatistics?.call();
|
||||
},
|
||||
),
|
||||
if (widget.onShowHelp != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: Icons.help_outline,
|
||||
iconColor: RetroColors.expOf(context),
|
||||
label: l10n.uiHelp.toUpperCase(),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onShowHelp?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// === 설정 ===
|
||||
RetroMenuSection(title: L10n.of(context).settingsSection),
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: Icons.language,
|
||||
iconColor: RetroColors.mpOf(context),
|
||||
label: l10n.menuLanguage.toUpperCase(),
|
||||
value: _getCurrentLanguageName(),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showLanguageDialog(context);
|
||||
},
|
||||
),
|
||||
if (widget.onBgmVolumeChange != null ||
|
||||
widget.onSfxVolumeChange != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: widget.bgmVolume == 0 && widget.sfxVolume == 0
|
||||
? Icons.volume_off
|
||||
: Icons.volume_up,
|
||||
iconColor: RetroColors.textMutedOf(context),
|
||||
label: l10n.uiSound.toUpperCase(),
|
||||
value: _getSoundStatus(),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showSoundDialog(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// === 저장/종료 ===
|
||||
RetroMenuSection(title: L10n.of(context).saveExitSection),
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: Icons.save,
|
||||
iconColor: RetroColors.mpOf(context),
|
||||
label: l10n.menuSave.toUpperCase(),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onSave();
|
||||
widget.notificationService.showGameSaved(
|
||||
l10n.menuSaved,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: Icons.refresh,
|
||||
iconColor: RetroColors.warningOf(context),
|
||||
label: l10n.menuNewGame.toUpperCase(),
|
||||
subtitle: l10n.menuDeleteSave,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showDeleteConfirmDialog(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: Icons.exit_to_app,
|
||||
iconColor: RetroColors.hpOf(context),
|
||||
label: localizations.exitGame.toUpperCase(),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onExit();
|
||||
},
|
||||
),
|
||||
|
||||
// === 치트 섹션 (디버그 모드에서만) ===
|
||||
if (widget.cheatsEnabled) ...[
|
||||
const SizedBox(height: 16),
|
||||
RetroMenuSection(
|
||||
title: L10n.of(context).debugCheatsTitle,
|
||||
color: RetroColors.hpOf(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: Icons.fast_forward,
|
||||
iconColor: RetroColors.hpOf(context),
|
||||
label: L10n.of(context).debugSkipTask,
|
||||
subtitle: L10n.of(context).debugSkipTaskDesc,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onCheatTask?.call();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: Icons.skip_next,
|
||||
iconColor: RetroColors.hpOf(context),
|
||||
label: L10n.of(context).debugSkipQuest,
|
||||
subtitle: L10n.of(context).debugSkipQuestDesc,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onCheatQuest?.call();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: Icons.double_arrow,
|
||||
iconColor: RetroColors.hpOf(context),
|
||||
label: L10n.of(context).debugSkipAct,
|
||||
subtitle: L10n.of(context).debugSkipActDesc,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onCheatPlot?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
// === 디버그 도구 섹션 ===
|
||||
if (kDebugMode &&
|
||||
widget.onCreateTestCharacter != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
RetroMenuSection(
|
||||
title: L10n.of(context).debugToolsTitle,
|
||||
color: RetroColors.warningOf(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: Icons.science,
|
||||
iconColor: RetroColors.warningOf(context),
|
||||
label: L10n.of(context).debugCreateTestCharacter,
|
||||
subtitle: L10n.of(
|
||||
context,
|
||||
).debugCreateTestCharacterDesc,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showTestCharacterDialog(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 레트로 스타일 속도 선택기
|
||||
///
|
||||
/// - 5x/20x 토글 버튼 하나만 표시
|
||||
/// - 부스트 활성화 중: 반투명, 비활성 (누를 수 없음)
|
||||
/// - 부스트 비활성화: 불투명, 활성 (누를 수 있음)
|
||||
Widget _buildRetroSpeedSelector(BuildContext context) {
|
||||
final isSpeedBoostActive = widget.isSpeedBoostActive;
|
||||
final adSpeed = widget.adSpeedMultiplier;
|
||||
|
||||
return RetroSpeedChip(
|
||||
speed: adSpeed,
|
||||
isSelected: isSpeedBoostActive,
|
||||
isAdBased: !isSpeedBoostActive && !widget.isPaidUser,
|
||||
// 부스트 활성화 중이면 비활성 (반투명)
|
||||
isDisabled: isSpeedBoostActive,
|
||||
onTap: () {
|
||||
if (!isSpeedBoostActive) {
|
||||
widget.onSpeedBoostActivate?.call();
|
||||
}
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = widget.state;
|
||||
@@ -594,7 +220,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
// 옵션 버튼
|
||||
IconButton(
|
||||
icon: Icon(Icons.settings, color: gold),
|
||||
onPressed: () => _showOptionsMenu(context),
|
||||
onPressed: () => _openOptionsMenu(context),
|
||||
tooltip: l10n.menuOptions,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/stats_panel.dart';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/potion.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/potion_inventory_panel.dart';
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/active_buff_panel.dart';
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/util/roman.dart' show intToRoman;
|
||||
|
||||
/// 스토리 페이지 (캐로셀)
|
||||
///
|
||||
@@ -69,7 +70,7 @@ class StoryPage extends StatelessWidget {
|
||||
final isCompleted = index < plotStageCount - 1;
|
||||
final label = index == 0
|
||||
? localizations.prologue
|
||||
: localizations.actNumber(_toRoman(index));
|
||||
: localizations.actNumber(intToRoman(index));
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
@@ -113,32 +114,4 @@ class StoryPage extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _toRoman(int number) {
|
||||
const romanNumerals = [
|
||||
(1000, 'M'),
|
||||
(900, 'CM'),
|
||||
(500, 'D'),
|
||||
(400, 'CD'),
|
||||
(100, 'C'),
|
||||
(90, 'XC'),
|
||||
(50, 'L'),
|
||||
(40, 'XL'),
|
||||
(10, 'X'),
|
||||
(9, 'IX'),
|
||||
(5, 'V'),
|
||||
(4, 'IV'),
|
||||
(1, 'I'),
|
||||
];
|
||||
|
||||
var result = '';
|
||||
var remaining = number;
|
||||
for (final (value, numeral) in romanNumerals) {
|
||||
while (remaining >= value) {
|
||||
result += numeral;
|
||||
remaining -= value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,24 +2,25 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/animation/ascii_animation_data.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/ascii_animation_data.dart';
|
||||
import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.dart';
|
||||
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
|
||||
import 'package:asciineverdie/src/core/animation/background_layer.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_widget.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/canvas_battle_composer.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/canvas_special_composer.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/canvas_town_composer.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/canvas_walking_composer.dart';
|
||||
import 'package:asciineverdie/src/core/animation/character_frames.dart';
|
||||
import 'package:asciineverdie/src/core/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/core/animation/weapon_category.dart';
|
||||
import 'package:asciineverdie/src/core/constants/ascii_colors.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/ascii_animation_type.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/background_layer.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_canvas_widget.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_layer.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/canvas_battle_composer.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/canvas_special_composer.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/canvas_town_composer.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/canvas_walking_composer.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/character_frames.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/weapon_category.dart';
|
||||
import 'package:asciineverdie/src/shared/theme/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';
|
||||
import 'package:asciineverdie/src/features/game/widgets/combat_event_mapping.dart';
|
||||
|
||||
/// 애니메이션 모드
|
||||
enum AnimationMode {
|
||||
@@ -284,198 +285,25 @@ 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,
|
||||
),
|
||||
// 스킬 사용 → prepare 페이즈부터 시작 + 스킬 이펙트
|
||||
CombatEventType.playerSkill => (
|
||||
BattlePhase.prepare,
|
||||
event.isCritical,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
|
||||
// 몬스터 공격 → prepare 페이즈부터 시작
|
||||
CombatEventType.monsterAttack => (
|
||||
BattlePhase.prepare,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
// 블록 → hit 페이즈 + 블록 이펙트 + 텍스트
|
||||
CombatEventType.playerBlock => (
|
||||
BattlePhase.hit,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
// 패리 → hit 페이즈 + 패리 이펙트 + 텍스트
|
||||
CombatEventType.playerParry => (
|
||||
BattlePhase.hit,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
|
||||
// 플레이어 회피 → recover 페이즈 + 회피 텍스트
|
||||
CombatEventType.playerEvade => (
|
||||
BattlePhase.recover,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
// 몬스터 회피 → idle 페이즈 + 미스 텍스트
|
||||
CombatEventType.monsterEvade => (
|
||||
BattlePhase.idle,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
|
||||
// 회복/버프 → idle 페이즈 유지
|
||||
CombatEventType.playerHeal => (
|
||||
BattlePhase.idle,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
CombatEventType.playerBuff => (
|
||||
BattlePhase.idle,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
|
||||
// 디버프 적용 → idle 페이즈 + 디버프 텍스트
|
||||
CombatEventType.playerDebuff => (
|
||||
BattlePhase.idle,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
),
|
||||
|
||||
// DOT 틱 → attack 페이즈 + DOT 텍스트
|
||||
CombatEventType.dotTick => (
|
||||
BattlePhase.attack,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
|
||||
// 물약 사용 → idle 페이즈 유지
|
||||
CombatEventType.playerPotion => (
|
||||
BattlePhase.idle,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
|
||||
// 물약 드랍 → idle 페이즈 유지
|
||||
CombatEventType.potionDrop => (
|
||||
BattlePhase.idle,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
};
|
||||
final effects = mapCombatEventToEffects(event);
|
||||
|
||||
setState(() {
|
||||
_battlePhase = targetPhase;
|
||||
_battlePhase = effects.targetPhase;
|
||||
_battleSubFrame = 0;
|
||||
_phaseFrameCount = 0;
|
||||
_showCriticalEffect = isCritical;
|
||||
_showBlockEffect = isBlock;
|
||||
_showParryEffect = isParry;
|
||||
_showSkillEffect = isSkill;
|
||||
_showEvadeEffect = isEvade;
|
||||
_showMissEffect = isMiss;
|
||||
_showDebuffEffect = isDebuff;
|
||||
_showDotEffect = isDot;
|
||||
_showCriticalEffect = effects.isCritical;
|
||||
_showBlockEffect = effects.isBlock;
|
||||
_showParryEffect = effects.isParry;
|
||||
_showSkillEffect = effects.isSkill;
|
||||
_showEvadeEffect = effects.isEvade;
|
||||
_showMissEffect = effects.isMiss;
|
||||
_showDebuffEffect = effects.isDebuff;
|
||||
_showDotEffect = effects.isDot;
|
||||
|
||||
// 페이즈 인덱스 동기화
|
||||
_phaseIndex = _battlePhaseSequence.indexWhere((p) => p.$1 == targetPhase);
|
||||
_phaseIndex = _battlePhaseSequence.indexWhere(
|
||||
(p) => p.$1 == effects.targetPhase,
|
||||
);
|
||||
if (_phaseIndex < 0) _phaseIndex = 0;
|
||||
|
||||
// 공격 속도에 따른 동적 페이즈 프레임 수 계산 (Phase 6)
|
||||
@@ -488,12 +316,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
}
|
||||
|
||||
// 공격자 타입 결정 (Phase 7: 공격자별 위치 분리)
|
||||
_currentAttacker = switch (event.type) {
|
||||
CombatEventType.playerAttack ||
|
||||
CombatEventType.playerSkill => AttackerType.player,
|
||||
CombatEventType.monsterAttack => AttackerType.monster,
|
||||
_ => AttackerType.none,
|
||||
};
|
||||
_currentAttacker = getAttackerType(event.type);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
165
lib/src/features/game/widgets/combat_event_mapping.dart
Normal file
165
lib/src/features/game/widgets/combat_event_mapping.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'package:asciineverdie/src/shared/animation/character_frames.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||
|
||||
/// 전투 이벤트 → 애니메이션 효과 매핑 결과
|
||||
typedef CombatEffects = ({
|
||||
BattlePhase targetPhase,
|
||||
bool isCritical,
|
||||
bool isBlock,
|
||||
bool isParry,
|
||||
bool isSkill,
|
||||
bool isEvade,
|
||||
bool isMiss,
|
||||
bool isDebuff,
|
||||
bool isDot,
|
||||
});
|
||||
|
||||
/// 전투 이벤트에 따른 애니메이션 효과 결정
|
||||
///
|
||||
/// CombatEvent 타입을 분석하여 대응하는 BattlePhase와 이펙트 플래그를 반환합니다.
|
||||
CombatEffects mapCombatEventToEffects(CombatEvent event) {
|
||||
return switch (event.type) {
|
||||
// 플레이어 공격 -> prepare 페이즈부터 시작 (준비 동작 표시)
|
||||
CombatEventType.playerAttack => (
|
||||
targetPhase: BattlePhase.prepare,
|
||||
isCritical: event.isCritical,
|
||||
isBlock: false,
|
||||
isParry: false,
|
||||
isSkill: false,
|
||||
isEvade: false,
|
||||
isMiss: false,
|
||||
isDebuff: false,
|
||||
isDot: false,
|
||||
),
|
||||
// 스킬 사용 -> prepare 페이즈부터 시작 + 스킬 이펙트
|
||||
CombatEventType.playerSkill => (
|
||||
targetPhase: BattlePhase.prepare,
|
||||
isCritical: event.isCritical,
|
||||
isBlock: false,
|
||||
isParry: false,
|
||||
isSkill: true,
|
||||
isEvade: false,
|
||||
isMiss: false,
|
||||
isDebuff: false,
|
||||
isDot: false,
|
||||
),
|
||||
// 몬스터 공격 -> prepare 페이즈부터 시작
|
||||
CombatEventType.monsterAttack => (
|
||||
targetPhase: BattlePhase.prepare,
|
||||
isCritical: false,
|
||||
isBlock: false,
|
||||
isParry: false,
|
||||
isSkill: false,
|
||||
isEvade: false,
|
||||
isMiss: false,
|
||||
isDebuff: false,
|
||||
isDot: false,
|
||||
),
|
||||
// 블록 -> hit 페이즈 + 블록 이펙트
|
||||
CombatEventType.playerBlock => (
|
||||
targetPhase: BattlePhase.hit,
|
||||
isCritical: false,
|
||||
isBlock: true,
|
||||
isParry: false,
|
||||
isSkill: false,
|
||||
isEvade: false,
|
||||
isMiss: false,
|
||||
isDebuff: false,
|
||||
isDot: false,
|
||||
),
|
||||
// 패리 -> hit 페이즈 + 패리 이펙트
|
||||
CombatEventType.playerParry => (
|
||||
targetPhase: BattlePhase.hit,
|
||||
isCritical: false,
|
||||
isBlock: false,
|
||||
isParry: true,
|
||||
isSkill: false,
|
||||
isEvade: false,
|
||||
isMiss: false,
|
||||
isDebuff: false,
|
||||
isDot: false,
|
||||
),
|
||||
// 플레이어 회피 -> recover 페이즈 + 회피 텍스트
|
||||
CombatEventType.playerEvade => (
|
||||
targetPhase: BattlePhase.recover,
|
||||
isCritical: false,
|
||||
isBlock: false,
|
||||
isParry: false,
|
||||
isSkill: false,
|
||||
isEvade: true,
|
||||
isMiss: false,
|
||||
isDebuff: false,
|
||||
isDot: false,
|
||||
),
|
||||
// 몬스터 회피 -> idle 페이즈 + 미스 텍스트
|
||||
CombatEventType.monsterEvade => (
|
||||
targetPhase: BattlePhase.idle,
|
||||
isCritical: false,
|
||||
isBlock: false,
|
||||
isParry: false,
|
||||
isSkill: false,
|
||||
isEvade: false,
|
||||
isMiss: true,
|
||||
isDebuff: false,
|
||||
isDot: false,
|
||||
),
|
||||
// 회복/버프 -> idle 페이즈 유지
|
||||
CombatEventType.playerHeal || CombatEventType.playerBuff => (
|
||||
targetPhase: BattlePhase.idle,
|
||||
isCritical: false,
|
||||
isBlock: false,
|
||||
isParry: false,
|
||||
isSkill: false,
|
||||
isEvade: false,
|
||||
isMiss: false,
|
||||
isDebuff: false,
|
||||
isDot: false,
|
||||
),
|
||||
// 디버프 적용 -> idle 페이즈 + 디버프 텍스트
|
||||
CombatEventType.playerDebuff => (
|
||||
targetPhase: BattlePhase.idle,
|
||||
isCritical: false,
|
||||
isBlock: false,
|
||||
isParry: false,
|
||||
isSkill: false,
|
||||
isEvade: false,
|
||||
isMiss: false,
|
||||
isDebuff: true,
|
||||
isDot: false,
|
||||
),
|
||||
// DOT 틱 -> attack 페이즈 + DOT 텍스트
|
||||
CombatEventType.dotTick => (
|
||||
targetPhase: BattlePhase.attack,
|
||||
isCritical: false,
|
||||
isBlock: false,
|
||||
isParry: false,
|
||||
isSkill: false,
|
||||
isEvade: false,
|
||||
isMiss: false,
|
||||
isDebuff: false,
|
||||
isDot: true,
|
||||
),
|
||||
// 물약 사용/드랍 -> idle 페이즈 유지
|
||||
CombatEventType.playerPotion || CombatEventType.potionDrop => (
|
||||
targetPhase: BattlePhase.idle,
|
||||
isCritical: false,
|
||||
isBlock: false,
|
||||
isParry: false,
|
||||
isSkill: false,
|
||||
isEvade: false,
|
||||
isMiss: false,
|
||||
isDebuff: false,
|
||||
isDot: false,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// 전투 이벤트에서 공격자 타입 결정 (Phase 7)
|
||||
AttackerType getAttackerType(CombatEventType type) {
|
||||
return switch (type) {
|
||||
CombatEventType.playerAttack ||
|
||||
CombatEventType.playerSkill => AttackerType.player,
|
||||
CombatEventType.monsterAttack => AttackerType.monster,
|
||||
_ => AttackerType.none,
|
||||
};
|
||||
}
|
||||
370
lib/src/features/game/widgets/compact_status_bars.dart
Normal file
370
lib/src/features/game/widgets/compact_status_bars.dart
Normal file
@@ -0,0 +1,370 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
||||
|
||||
/// 컴팩트 HP 바 (숫자 오버레이 포함)
|
||||
class CompactHpBar extends StatelessWidget {
|
||||
const CompactHpBar({
|
||||
super.key,
|
||||
required this.current,
|
||||
required this.max,
|
||||
required this.flashAnimation,
|
||||
required this.hpChange,
|
||||
});
|
||||
|
||||
final int current;
|
||||
final int max;
|
||||
final Animation<double> flashAnimation;
|
||||
final int hpChange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ratio = max > 0 ? current / max : 0.0;
|
||||
final isLow = ratio < 0.2 && ratio > 0;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: flashAnimation,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isLow
|
||||
? Colors.red.withValues(alpha: 0.2)
|
||||
: Colors.grey.shade800,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
l10n.statHp,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
right: Radius.circular(3),
|
||||
),
|
||||
child: SizedBox.expand(
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio.clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.red.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
isLow ? Colors.red : Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$current/$max',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(alpha: 0.9),
|
||||
blurRadius: 2,
|
||||
),
|
||||
const Shadow(color: Colors.black, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hpChange != 0 && flashAnimation.value > 0.05)
|
||||
Positioned(
|
||||
right: 20,
|
||||
top: -8,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -10 * (1 - flashAnimation.value)),
|
||||
child: Opacity(
|
||||
opacity: flashAnimation.value,
|
||||
child: Text(
|
||||
hpChange > 0 ? '+$hpChange' : '$hpChange',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: hpChange < 0 ? Colors.red : Colors.green,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black, blurRadius: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 컴팩트 MP 바 (숫자 오버레이 포함)
|
||||
class CompactMpBar extends StatelessWidget {
|
||||
const CompactMpBar({
|
||||
super.key,
|
||||
required this.current,
|
||||
required this.max,
|
||||
required this.flashAnimation,
|
||||
required this.mpChange,
|
||||
});
|
||||
|
||||
final int current;
|
||||
final int max;
|
||||
final Animation<double> flashAnimation;
|
||||
final int mpChange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ratio = max > 0 ? current / max : 0.0;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: flashAnimation,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade800,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
l10n.statMp,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
right: Radius.circular(3),
|
||||
),
|
||||
child: SizedBox.expand(
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio.clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.blue.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
Colors.blue.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$current/$max',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(alpha: 0.9),
|
||||
blurRadius: 2,
|
||||
),
|
||||
const Shadow(color: Colors.black, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (mpChange != 0 && flashAnimation.value > 0.05)
|
||||
Positioned(
|
||||
right: 20,
|
||||
top: -8,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -10 * (1 - flashAnimation.value)),
|
||||
child: Opacity(
|
||||
opacity: flashAnimation.value,
|
||||
child: Text(
|
||||
mpChange > 0 ? '+$mpChange' : '$mpChange',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: mpChange < 0 ? Colors.orange : Colors.cyan,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black, blurRadius: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 몬스터 HP 바 (전투 중)
|
||||
class CompactMonsterHpBar extends StatelessWidget {
|
||||
const CompactMonsterHpBar({
|
||||
super.key,
|
||||
required this.combat,
|
||||
required this.monsterHpCurrent,
|
||||
required this.monsterHpMax,
|
||||
required this.monsterLevel,
|
||||
required this.flashAnimation,
|
||||
required this.monsterHpChange,
|
||||
});
|
||||
|
||||
final CombatState combat;
|
||||
final int? monsterHpCurrent;
|
||||
final int? monsterHpMax;
|
||||
final int? monsterLevel;
|
||||
final Animation<double> flashAnimation;
|
||||
final int monsterHpChange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final max = monsterHpMax ?? 1;
|
||||
final current = monsterHpCurrent ?? 0;
|
||||
final ratio = max > 0 ? current / max : 0.0;
|
||||
final monsterName = combat.monsterStats.name;
|
||||
final level = monsterLevel ?? combat.monsterStats.level;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: flashAnimation,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: Colors.orange.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 2),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: SizedBox.expand(
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio.clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.orange.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
valueColor: const AlwaysStoppedAnimation(
|
||||
Colors.orange,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${(ratio * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(alpha: 0.8),
|
||||
blurRadius: 2,
|
||||
),
|
||||
const Shadow(
|
||||
color: Colors.black,
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
|
||||
child: Text(
|
||||
'Lv.$level $monsterName',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (monsterHpChange != 0 && flashAnimation.value > 0.05)
|
||||
Positioned(
|
||||
right: 10,
|
||||
top: -10,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -10 * (1 - flashAnimation.value)),
|
||||
child: Opacity(
|
||||
opacity: flashAnimation.value,
|
||||
child: Text(
|
||||
monsterHpChange > 0
|
||||
? '+$monsterHpChange'
|
||||
: '$monsterHpChange',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: monsterHpChange < 0
|
||||
? Colors.yellow
|
||||
: Colors.green,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black, blurRadius: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
229
lib/src/features/game/widgets/death_buttons.dart
Normal file
229
lib/src/features/game/widgets/death_buttons.dart
Normal file
@@ -0,0 +1,229 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 일반 부활 버튼 (HP 50%, 아이템 희생)
|
||||
class DeathResurrectButton extends StatelessWidget {
|
||||
const DeathResurrectButton({super.key, required this.onResurrect});
|
||||
|
||||
final VoidCallback onResurrect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final expColor = RetroColors.expOf(context);
|
||||
final expDark = RetroColors.expDarkOf(context);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onResurrect,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: expColor.withValues(alpha: 0.2),
|
||||
border: Border(
|
||||
top: BorderSide(color: expColor, width: 3),
|
||||
left: BorderSide(color: expColor, width: 3),
|
||||
bottom: BorderSide(color: expDark.withValues(alpha: 0.8), width: 3),
|
||||
right: BorderSide(color: expDark.withValues(alpha: 0.8), width: 3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'\u21BA',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
color: expColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.deathResurrect.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: expColor,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 광고 부활 버튼 (HP 100% + 아이템 복구 + 10분 자동부활)
|
||||
class DeathAdReviveButton extends StatelessWidget {
|
||||
const DeathAdReviveButton({
|
||||
super.key,
|
||||
required this.onAdRevive,
|
||||
required this.deathInfo,
|
||||
required this.isPaidUser,
|
||||
});
|
||||
|
||||
final VoidCallback onAdRevive;
|
||||
final DeathInfo deathInfo;
|
||||
final bool isPaidUser;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final gold = RetroColors.goldOf(context);
|
||||
final goldDark = RetroColors.goldDarkOf(context);
|
||||
final muted = RetroColors.textMutedOf(context);
|
||||
final hasLostItem = deathInfo.lostItemName != null;
|
||||
final itemRarityColor = _getRarityColor(deathInfo.lostItemRarity);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onAdRevive,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: gold.withValues(alpha: 0.2),
|
||||
border: Border(
|
||||
top: BorderSide(color: gold, width: 3),
|
||||
left: BorderSide(color: gold, width: 3),
|
||||
bottom: BorderSide(
|
||||
color: goldDark.withValues(alpha: 0.8),
|
||||
width: 3,
|
||||
),
|
||||
right: BorderSide(color: goldDark.withValues(alpha: 0.8), width: 3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 메인 버튼 텍스트
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('\u2728', style: TextStyle(fontSize: 20, color: gold)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.deathAdRevive.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: gold,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
// 광고 뱃지 (무료 유저만)
|
||||
if (!isPaidUser) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'\u25B6 AD',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 혜택 목록
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_BenefitRow(
|
||||
icon: '\u2665',
|
||||
text: l10n.deathAdReviveHp,
|
||||
color: RetroColors.hpOf(context),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (hasLostItem) ...[
|
||||
_BenefitRow(
|
||||
icon: '\u{1F504}',
|
||||
text:
|
||||
'${l10n.deathAdReviveItem}: ${deathInfo.lostItemName}',
|
||||
color: itemRarityColor,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
_BenefitRow(
|
||||
icon: '\u23F1',
|
||||
text: l10n.deathAdReviveAuto,
|
||||
color: RetroColors.mpOf(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (isPaidUser)
|
||||
Text(
|
||||
l10n.deathAdRevivePaidDesc,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 9,
|
||||
color: muted,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getRarityColor(ItemRarity? rarity) {
|
||||
if (rarity == null) return Colors.grey;
|
||||
return switch (rarity) {
|
||||
ItemRarity.common => Colors.grey,
|
||||
ItemRarity.uncommon => Colors.green,
|
||||
ItemRarity.rare => Colors.blue,
|
||||
ItemRarity.epic => Colors.purple,
|
||||
ItemRarity.legendary => Colors.orange,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 혜택 항목 행
|
||||
class _BenefitRow extends StatelessWidget {
|
||||
const _BenefitRow({
|
||||
required this.icon,
|
||||
required this.text,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final String icon;
|
||||
final String text;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(icon, style: TextStyle(fontSize: 14, color: color)),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: color,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
171
lib/src/features/game/widgets/death_combat_log.dart
Normal file
171
lib/src/features/game/widgets/death_combat_log.dart
Normal file
@@ -0,0 +1,171 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 사망 화면 전투 로그 위젯
|
||||
class DeathCombatLog extends StatelessWidget {
|
||||
const DeathCombatLog({super.key, required this.events});
|
||||
|
||||
final List<CombatEvent> events;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final gold = RetroColors.goldOf(context);
|
||||
final background = RetroColors.backgroundOf(context);
|
||||
final borderColor = RetroColors.borderOf(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('\u{1F4DC}', style: TextStyle(fontSize: 17)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
l10n.deathCombatLog.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: gold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 100),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
border: Border.all(color: borderColor, width: 2),
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.all(6),
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) {
|
||||
final event = events[index];
|
||||
return _CombatEventTile(event: event);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 개별 전투 이벤트 타일
|
||||
class _CombatEventTile extends StatelessWidget {
|
||||
const _CombatEventTile({required this.event});
|
||||
|
||||
final CombatEvent event;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (asciiIcon, color, message) = _formatCombatEvent(context, event);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(asciiIcon, style: TextStyle(fontSize: 15, color: color)),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 14,
|
||||
color: color,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 전투 이벤트를 ASCII 아이콘, 색상, 메시지로 포맷
|
||||
(String, Color, String) _formatCombatEvent(
|
||||
BuildContext context,
|
||||
CombatEvent event,
|
||||
) {
|
||||
final target = event.targetName ?? '';
|
||||
final gold = RetroColors.goldOf(context);
|
||||
final exp = RetroColors.expOf(context);
|
||||
final hp = RetroColors.hpOf(context);
|
||||
final mp = RetroColors.mpOf(context);
|
||||
|
||||
return switch (event.type) {
|
||||
CombatEventType.playerAttack => (
|
||||
event.isCritical ? '\u26A1' : '\u2694',
|
||||
event.isCritical ? gold : exp,
|
||||
event.isCritical
|
||||
? l10n.combatCritical(event.damage, target)
|
||||
: l10n.combatYouHit(target, event.damage),
|
||||
),
|
||||
CombatEventType.monsterAttack => (
|
||||
'\u{1F480}',
|
||||
hp,
|
||||
l10n.combatMonsterHitsYou(target, event.damage),
|
||||
),
|
||||
CombatEventType.playerEvade => (
|
||||
'\u27A4',
|
||||
RetroColors.asciiCyan,
|
||||
l10n.combatEvadedAttackFrom(target),
|
||||
),
|
||||
CombatEventType.monsterEvade => (
|
||||
'\u27A4',
|
||||
const Color(0xFFFF9933),
|
||||
l10n.combatMonsterEvaded(target),
|
||||
),
|
||||
CombatEventType.playerBlock => (
|
||||
'\u{1F6E1}',
|
||||
mp,
|
||||
l10n.combatBlockedAttack(target, event.damage),
|
||||
),
|
||||
CombatEventType.playerParry => (
|
||||
'\u2694',
|
||||
const Color(0xFF00CCCC),
|
||||
l10n.combatParriedAttack(target, event.damage),
|
||||
),
|
||||
CombatEventType.playerSkill => (
|
||||
'\u2727',
|
||||
const Color(0xFF9966FF),
|
||||
l10n.combatSkillDamage(event.skillName ?? '', event.damage),
|
||||
),
|
||||
CombatEventType.playerHeal => (
|
||||
'\u2665',
|
||||
exp,
|
||||
l10n.combatHealedFor(event.healAmount),
|
||||
),
|
||||
CombatEventType.playerBuff => (
|
||||
'\u2191',
|
||||
mp,
|
||||
l10n.combatBuffActivated(event.skillName ?? ''),
|
||||
),
|
||||
CombatEventType.playerDebuff => (
|
||||
'\u2193',
|
||||
const Color(0xFFFF6633),
|
||||
l10n.combatDebuffApplied(event.skillName ?? '', target),
|
||||
),
|
||||
CombatEventType.dotTick => (
|
||||
'\u{1F525}',
|
||||
const Color(0xFFFF6633),
|
||||
l10n.combatDotTick(event.skillName ?? '', event.damage),
|
||||
),
|
||||
CombatEventType.playerPotion => (
|
||||
'\u{1F9EA}',
|
||||
exp,
|
||||
l10n.combatPotionUsed(event.skillName ?? '', event.healAmount, target),
|
||||
),
|
||||
CombatEventType.potionDrop => (
|
||||
'\u{1F381}',
|
||||
gold,
|
||||
l10n.combatPotionDrop(event.skillName ?? ''),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.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/item_stats.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/death_buttons.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/death_combat_log.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 사망 오버레이 위젯
|
||||
@@ -133,18 +134,22 @@ class DeathOverlay extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
_buildRetroDivider(hpColor, hpDark),
|
||||
const SizedBox(height: 8),
|
||||
_buildCombatLog(context),
|
||||
DeathCombatLog(events: deathInfo.lastCombatEvents),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 일반 부활 버튼 (HP 50%, 아이템 희생)
|
||||
_buildResurrectButton(context),
|
||||
DeathResurrectButton(onResurrect: onResurrect),
|
||||
|
||||
// 광고 부활 버튼 (HP 100% + 아이템 복구 + 10분 자동부활)
|
||||
if (onAdRevive != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildAdReviveButton(context),
|
||||
DeathAdReviveButton(
|
||||
onAdRevive: onAdRevive!,
|
||||
deathInfo: deathInfo,
|
||||
isPaidUser: isPaidUser,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -423,347 +428,6 @@ class DeathOverlay extends StatelessWidget {
|
||||
return gold.toString();
|
||||
}
|
||||
|
||||
Widget _buildResurrectButton(BuildContext context) {
|
||||
final expColor = RetroColors.expOf(context);
|
||||
final expDark = RetroColors.expDarkOf(context);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onResurrect,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: expColor.withValues(alpha: 0.2),
|
||||
border: Border(
|
||||
top: BorderSide(color: expColor, width: 3),
|
||||
left: BorderSide(color: expColor, width: 3),
|
||||
bottom: BorderSide(color: expDark.withValues(alpha: 0.8), width: 3),
|
||||
right: BorderSide(color: expDark.withValues(alpha: 0.8), width: 3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'↺',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
color: expColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.deathResurrect.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: expColor,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 광고 부활 버튼 (HP 100% + 아이템 복구 + 10분 자동부활)
|
||||
Widget _buildAdReviveButton(BuildContext context) {
|
||||
final gold = RetroColors.goldOf(context);
|
||||
final goldDark = RetroColors.goldDarkOf(context);
|
||||
final muted = RetroColors.textMutedOf(context);
|
||||
final hasLostItem = deathInfo.lostItemName != null;
|
||||
final itemRarityColor = _getRarityColor(deathInfo.lostItemRarity);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onAdRevive,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: gold.withValues(alpha: 0.2),
|
||||
border: Border(
|
||||
top: BorderSide(color: gold, width: 3),
|
||||
left: BorderSide(color: gold, width: 3),
|
||||
bottom: BorderSide(
|
||||
color: goldDark.withValues(alpha: 0.8),
|
||||
width: 3,
|
||||
),
|
||||
right: BorderSide(color: goldDark.withValues(alpha: 0.8), width: 3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 메인 버튼 텍스트
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('✨', style: TextStyle(fontSize: 20, color: gold)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.deathAdRevive.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: gold,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
// 광고 뱃지 (무료 유저만)
|
||||
if (!isPaidUser) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'▶ AD',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 혜택 목록
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// HP 100% 회복
|
||||
_buildBenefitRow(
|
||||
context,
|
||||
icon: '♥',
|
||||
text: l10n.deathAdReviveHp,
|
||||
color: RetroColors.hpOf(context),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 아이템 복구 (잃은 아이템이 있을 때만)
|
||||
if (hasLostItem) ...[
|
||||
_buildBenefitRow(
|
||||
context,
|
||||
icon: '🔄',
|
||||
text:
|
||||
'${l10n.deathAdReviveItem}: ${deathInfo.lostItemName}',
|
||||
color: itemRarityColor,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
// 10분 자동부활
|
||||
_buildBenefitRow(
|
||||
context,
|
||||
icon: '⏱',
|
||||
text: l10n.deathAdReviveAuto,
|
||||
color: RetroColors.mpOf(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// 유료 유저 설명
|
||||
if (isPaidUser)
|
||||
Text(
|
||||
l10n.deathAdRevivePaidDesc,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 9,
|
||||
color: muted,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 혜택 항목 행
|
||||
Widget _buildBenefitRow(
|
||||
BuildContext context, {
|
||||
required String icon,
|
||||
required String text,
|
||||
required Color color,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(icon, style: TextStyle(fontSize: 14, color: color)),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: color,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 사망 직전 전투 로그 표시
|
||||
Widget _buildCombatLog(BuildContext context) {
|
||||
final events = deathInfo.lastCombatEvents;
|
||||
final gold = RetroColors.goldOf(context);
|
||||
final background = RetroColors.backgroundOf(context);
|
||||
final borderColor = RetroColors.borderOf(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('📜', style: TextStyle(fontSize: 17)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
l10n.deathCombatLog.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: gold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 100),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
border: Border.all(color: borderColor, width: 2),
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.all(6),
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) {
|
||||
final event = events[index];
|
||||
return _buildCombatEventTile(context, event);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 개별 전투 이벤트 타일
|
||||
Widget _buildCombatEventTile(BuildContext context, CombatEvent event) {
|
||||
final (asciiIcon, color, message) = _formatCombatEvent(context, event);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(asciiIcon, style: TextStyle(fontSize: 15, color: color)),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 14,
|
||||
color: color,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 전투 이벤트를 ASCII 아이콘, 색상, 메시지로 포맷
|
||||
(String, Color, String) _formatCombatEvent(
|
||||
BuildContext context,
|
||||
CombatEvent event,
|
||||
) {
|
||||
final target = event.targetName ?? '';
|
||||
final gold = RetroColors.goldOf(context);
|
||||
final exp = RetroColors.expOf(context);
|
||||
final hp = RetroColors.hpOf(context);
|
||||
final mp = RetroColors.mpOf(context);
|
||||
|
||||
return switch (event.type) {
|
||||
CombatEventType.playerAttack => (
|
||||
event.isCritical ? '⚡' : '⚔',
|
||||
event.isCritical ? gold : exp,
|
||||
event.isCritical
|
||||
? l10n.combatCritical(event.damage, target)
|
||||
: l10n.combatYouHit(target, event.damage),
|
||||
),
|
||||
CombatEventType.monsterAttack => (
|
||||
'💀',
|
||||
hp,
|
||||
l10n.combatMonsterHitsYou(target, event.damage),
|
||||
),
|
||||
CombatEventType.playerEvade => (
|
||||
'➤',
|
||||
RetroColors.asciiCyan,
|
||||
l10n.combatEvadedAttackFrom(target),
|
||||
),
|
||||
CombatEventType.monsterEvade => (
|
||||
'➤',
|
||||
const Color(0xFFFF9933),
|
||||
l10n.combatMonsterEvaded(target),
|
||||
),
|
||||
CombatEventType.playerBlock => (
|
||||
'🛡',
|
||||
mp,
|
||||
l10n.combatBlockedAttack(target, event.damage),
|
||||
),
|
||||
CombatEventType.playerParry => (
|
||||
'⚔',
|
||||
const Color(0xFF00CCCC),
|
||||
l10n.combatParriedAttack(target, event.damage),
|
||||
),
|
||||
CombatEventType.playerSkill => (
|
||||
'✧',
|
||||
const Color(0xFF9966FF),
|
||||
l10n.combatSkillDamage(event.skillName ?? '', event.damage),
|
||||
),
|
||||
CombatEventType.playerHeal => (
|
||||
'♥',
|
||||
exp,
|
||||
l10n.combatHealedFor(event.healAmount),
|
||||
),
|
||||
CombatEventType.playerBuff => (
|
||||
'↑',
|
||||
mp,
|
||||
l10n.combatBuffActivated(event.skillName ?? ''),
|
||||
),
|
||||
CombatEventType.playerDebuff => (
|
||||
'↓',
|
||||
const Color(0xFFFF6633),
|
||||
l10n.combatDebuffApplied(event.skillName ?? '', target),
|
||||
),
|
||||
CombatEventType.dotTick => (
|
||||
'🔥',
|
||||
const Color(0xFFFF6633),
|
||||
l10n.combatDotTick(event.skillName ?? '', event.damage),
|
||||
),
|
||||
CombatEventType.playerPotion => (
|
||||
'🧪',
|
||||
exp,
|
||||
l10n.combatPotionUsed(event.skillName ?? '', event.healAmount, target),
|
||||
),
|
||||
CombatEventType.potionDrop => (
|
||||
'🎁',
|
||||
gold,
|
||||
l10n.combatPotionDrop(event.skillName ?? ''),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// 장비 슬롯 이름 반환
|
||||
String _getSlotName(EquipmentSlot? slot) {
|
||||
|
||||
256
lib/src/features/game/widgets/desktop_character_panel.dart
Normal file
256
lib/src/features/game/widgets/desktop_character_panel.dart
Normal file
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/desktop_panel_widgets.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/hp_mp_bar.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/stats_panel.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/active_buff_panel.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 데스크톱 좌측 패널: Character Sheet
|
||||
///
|
||||
/// Traits, Stats, HP/MP, Experience, SpellBook, Buffs 표시
|
||||
class DesktopCharacterPanel extends StatelessWidget {
|
||||
const DesktopCharacterPanel({super.key, required this.state});
|
||||
|
||||
final GameState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(4),
|
||||
color: RetroColors.panelBg,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(
|
||||
color: RetroColors.panelBorderOuter,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DesktopPanelHeader(title: l10n.characterSheet),
|
||||
DesktopSectionHeader(title: l10n.traits),
|
||||
_TraitsList(state: state),
|
||||
DesktopSectionHeader(title: l10n.stats),
|
||||
Expanded(flex: 2, child: StatsPanel(stats: state.stats)),
|
||||
HpMpBar(
|
||||
hpCurrent:
|
||||
state.progress.currentCombat?.playerStats.hpCurrent ??
|
||||
state.stats.hp,
|
||||
hpMax:
|
||||
state.progress.currentCombat?.playerStats.hpMax ??
|
||||
state.stats.hpMax,
|
||||
mpCurrent:
|
||||
state.progress.currentCombat?.playerStats.mpCurrent ??
|
||||
state.stats.mp,
|
||||
mpMax:
|
||||
state.progress.currentCombat?.playerStats.mpMax ??
|
||||
state.stats.mpMax,
|
||||
monsterHpCurrent:
|
||||
state.progress.currentCombat?.monsterStats.hpCurrent,
|
||||
monsterHpMax: state.progress.currentCombat?.monsterStats.hpMax,
|
||||
monsterName: state.progress.currentCombat?.monsterStats.name,
|
||||
monsterLevel: state.progress.currentCombat?.monsterStats.level,
|
||||
),
|
||||
DesktopSectionHeader(title: l10n.experience),
|
||||
DesktopSegmentProgressBar(
|
||||
position: state.progress.exp.position,
|
||||
max: state.progress.exp.max,
|
||||
color: Colors.blue,
|
||||
tooltip:
|
||||
'${state.progress.exp.position} / ${state.progress.exp.max}',
|
||||
),
|
||||
DesktopSectionHeader(title: l10n.spellBook),
|
||||
Expanded(flex: 3, child: _SkillsList(state: state)),
|
||||
DesktopSectionHeader(title: game_l10n.uiBuffs),
|
||||
Expanded(
|
||||
child: ActiveBuffPanel(
|
||||
activeBuffs: state.skillSystem.activeBuffs,
|
||||
currentMs: state.skillSystem.elapsedMs,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Traits 목록 위젯
|
||||
class _TraitsList extends StatelessWidget {
|
||||
const _TraitsList({required this.state});
|
||||
|
||||
final GameState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
final traits = [
|
||||
(l10n.traitName, state.traits.name),
|
||||
(l10n.traitRace, GameDataL10n.getRaceName(context, state.traits.race)),
|
||||
(
|
||||
l10n.traitClass,
|
||||
GameDataL10n.getKlassName(context, state.traits.klass),
|
||||
),
|
||||
(l10n.traitLevel, '${state.traits.level}'),
|
||||
];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Column(
|
||||
children: traits.map((t) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 50,
|
||||
child: Text(
|
||||
t.$1.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
t.$2,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.textLight,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 통합 스킬 목록 (SkillBook 기반)
|
||||
class _SkillsList extends StatelessWidget {
|
||||
const _SkillsList({required this.state});
|
||||
|
||||
final GameState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (state.skillBook.skills.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
L10n.of(context).noSpellsYet,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: state.skillBook.skills.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final skillEntry = state.skillBook.skills[index];
|
||||
final skill = SkillData.getSkillBySpellName(skillEntry.name);
|
||||
final skillName = GameDataL10n.getSpellName(context, skillEntry.name);
|
||||
|
||||
final skillState = skill != null
|
||||
? state.skillSystem.getSkillState(skill.id)
|
||||
: null;
|
||||
final isOnCooldown =
|
||||
skillState != null &&
|
||||
!skillState.isReady(
|
||||
state.skillSystem.elapsedMs,
|
||||
skill!.cooldownMs,
|
||||
);
|
||||
|
||||
return _SkillRow(
|
||||
skillName: skillName,
|
||||
rank: skillEntry.rank,
|
||||
skill: skill,
|
||||
isOnCooldown: isOnCooldown,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 스킬 행 위젯
|
||||
class _SkillRow extends StatelessWidget {
|
||||
const _SkillRow({
|
||||
required this.skillName,
|
||||
required this.rank,
|
||||
required this.skill,
|
||||
required this.isOnCooldown,
|
||||
});
|
||||
|
||||
final String skillName;
|
||||
final String rank;
|
||||
final Skill? skill;
|
||||
final bool isOnCooldown;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildTypeIcon(),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
skillName,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: isOnCooldown ? Colors.grey : null,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (isOnCooldown)
|
||||
const Icon(
|
||||
Icons.hourglass_empty,
|
||||
size: 10,
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
rank,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeIcon() {
|
||||
if (skill == null) {
|
||||
return const SizedBox(width: 12);
|
||||
}
|
||||
|
||||
final (IconData icon, Color color) = switch (skill!.type) {
|
||||
SkillType.attack => (Icons.flash_on, Colors.red),
|
||||
SkillType.heal => (Icons.favorite, Colors.green),
|
||||
SkillType.buff => (Icons.arrow_upward, Colors.blue),
|
||||
SkillType.debuff => (Icons.arrow_downward, Colors.purple),
|
||||
};
|
||||
|
||||
return Icon(icon, size: 12, color: color);
|
||||
}
|
||||
}
|
||||
168
lib/src/features/game/widgets/desktop_equipment_panel.dart
Normal file
168
lib/src/features/game/widgets/desktop_equipment_panel.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/inventory.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/desktop_panel_widgets.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/equipment_stats_panel.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/potion_inventory_panel.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 데스크톱 중앙 패널: Equipment/Inventory
|
||||
///
|
||||
/// Equipment, Inventory, Potions, Encumbrance, Combat Log 표시
|
||||
class DesktopEquipmentPanel extends StatelessWidget {
|
||||
const DesktopEquipmentPanel({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.combatLogEntries,
|
||||
});
|
||||
|
||||
final GameState state;
|
||||
final List<CombatLogEntry> combatLogEntries;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(4),
|
||||
color: RetroColors.panelBg,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(
|
||||
color: RetroColors.panelBorderOuter,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DesktopPanelHeader(title: l10n.equipment),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: EquipmentStatsPanel(equipment: state.equipment),
|
||||
),
|
||||
DesktopPanelHeader(title: l10n.inventory),
|
||||
Expanded(child: _InventoryList(state: state)),
|
||||
DesktopSectionHeader(title: game_l10n.uiPotions),
|
||||
Expanded(
|
||||
child: PotionInventoryPanel(inventory: state.potionInventory),
|
||||
),
|
||||
DesktopSectionHeader(title: l10n.encumbrance),
|
||||
DesktopSegmentProgressBar(
|
||||
position: state.progress.encumbrance.position,
|
||||
max: state.progress.encumbrance.max,
|
||||
color: Colors.orange,
|
||||
),
|
||||
DesktopPanelHeader(title: l10n.combatLog),
|
||||
Expanded(flex: 2, child: CombatLog(entries: combatLogEntries)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 인벤토리 목록 위젯
|
||||
class _InventoryList extends StatelessWidget {
|
||||
const _InventoryList({required this.state});
|
||||
|
||||
final GameState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
if (state.inventory.items.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
l10n.goldAmount(state.inventory.gold),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: state.inventory.items.length + 1,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return _buildGoldRow(l10n);
|
||||
}
|
||||
return _buildItemRow(context, state.inventory.items[index - 1]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGoldRow(L10n l10n) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.monetization_on,
|
||||
size: 10,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.gold.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${state.inventory.gold}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItemRow(BuildContext context, InventoryEntry item) {
|
||||
final translatedName = GameDataL10n.translateItemString(
|
||||
context,
|
||||
item.name,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
translatedName,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.textLight,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${item.count}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.cream,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
124
lib/src/features/game/widgets/desktop_panel_widgets.dart
Normal file
124
lib/src/features/game/widgets/desktop_panel_widgets.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 데스크톱 3패널 레이아웃에서 사용하는 공통 위젯들
|
||||
///
|
||||
/// - 패널 헤더 (금색 테두리)
|
||||
/// - 섹션 헤더 (비활성 텍스트)
|
||||
/// - 세그먼트 프로그레스 바
|
||||
|
||||
/// 패널 헤더 (Panel Header)
|
||||
class DesktopPanelHeader extends StatelessWidget {
|
||||
const DesktopPanelHeader({super.key, required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: const BoxDecoration(
|
||||
color: RetroColors.darkBrown,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: RetroColors.gold, width: 2),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 섹션 헤더 (Section Header)
|
||||
class DesktopSectionHeader extends StatelessWidget {
|
||||
const DesktopSectionHeader({super.key, required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 레트로 스타일 세그먼트 프로그레스 바 (Segment Progress Bar)
|
||||
class DesktopSegmentProgressBar extends StatelessWidget {
|
||||
const DesktopSegmentProgressBar({
|
||||
super.key,
|
||||
required this.position,
|
||||
required this.max,
|
||||
required this.color,
|
||||
this.tooltip,
|
||||
});
|
||||
|
||||
final int position;
|
||||
final int max;
|
||||
final Color color;
|
||||
final String? tooltip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = max > 0 ? (position / max).clamp(0.0, 1.0) : 0.0;
|
||||
const segmentCount = 20;
|
||||
final filledSegments = (progress * segmentCount).round();
|
||||
|
||||
final bar = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Container(
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
border: Border.all(
|
||||
color: RetroColors.panelBorderOuter,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: List.generate(segmentCount, (index) {
|
||||
final isFilled = index < filledSegments;
|
||||
return Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isFilled ? color : color.withValues(alpha: 0.1),
|
||||
border: Border(
|
||||
right: index < segmentCount - 1
|
||||
? BorderSide(
|
||||
color: RetroColors.panelBorderOuter.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
width: 1,
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (tooltip != null && tooltip!.isNotEmpty) {
|
||||
return Tooltip(message: tooltip!, child: bar);
|
||||
}
|
||||
return bar;
|
||||
}
|
||||
}
|
||||
212
lib/src/features/game/widgets/desktop_quest_panel.dart
Normal file
212
lib/src/features/game/widgets/desktop_quest_panel.dart
Normal file
@@ -0,0 +1,212 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||
import 'package:asciineverdie/src/core/util/roman.dart' show intToRoman;
|
||||
import 'package:asciineverdie/src/features/game/widgets/desktop_panel_widgets.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 데스크톱 우측 패널: Plot/Quest
|
||||
///
|
||||
/// Plot Development, Quests 목록 및 프로그레스 바 표시
|
||||
class DesktopQuestPanel extends StatelessWidget {
|
||||
const DesktopQuestPanel({super.key, required this.state});
|
||||
|
||||
final GameState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(4),
|
||||
color: RetroColors.panelBg,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(
|
||||
color: RetroColors.panelBorderOuter,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DesktopPanelHeader(title: l10n.plotDevelopment),
|
||||
Expanded(child: _PlotList(state: state)),
|
||||
DesktopSegmentProgressBar(
|
||||
position: state.progress.plot.position,
|
||||
max: state.progress.plot.max,
|
||||
color: Colors.purple,
|
||||
tooltip: state.progress.plot.max > 0
|
||||
? '${pq_logic.roughTime(state.progress.plot.max - state.progress.plot.position)} remaining'
|
||||
: null,
|
||||
),
|
||||
DesktopPanelHeader(title: l10n.quests),
|
||||
Expanded(child: _QuestList(state: state)),
|
||||
DesktopSegmentProgressBar(
|
||||
position: state.progress.quest.position,
|
||||
max: state.progress.quest.max,
|
||||
color: Colors.green,
|
||||
tooltip: state.progress.quest.max > 0
|
||||
? l10n.percentComplete(
|
||||
100 *
|
||||
state.progress.quest.position ~/
|
||||
state.progress.quest.max,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Plot 목록 위젯
|
||||
class _PlotList extends StatelessWidget {
|
||||
const _PlotList({required this.state});
|
||||
|
||||
final GameState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
final plotCount = state.progress.plotStageCount;
|
||||
if (plotCount == 0) {
|
||||
return Center(
|
||||
child: Text(
|
||||
l10n.prologue.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: plotCount,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final isCompleted = index < plotCount - 1;
|
||||
final isCurrent = index == plotCount - 1;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isCompleted
|
||||
? Icons.check_box
|
||||
: (isCurrent
|
||||
? Icons.arrow_right
|
||||
: Icons.check_box_outline_blank),
|
||||
size: 12,
|
||||
color: isCompleted
|
||||
? RetroColors.expGreen
|
||||
: (isCurrent
|
||||
? RetroColors.gold
|
||||
: RetroColors.textDisabled),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
index == 0
|
||||
? l10n.prologue
|
||||
: l10n.actNumber(intToRoman(index)),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: isCompleted
|
||||
? RetroColors.textDisabled
|
||||
: (isCurrent
|
||||
? RetroColors.gold
|
||||
: RetroColors.textLight),
|
||||
decoration: isCompleted
|
||||
? TextDecoration.lineThrough
|
||||
: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Quest 목록 위젯
|
||||
class _QuestList extends StatelessWidget {
|
||||
const _QuestList({required this.state});
|
||||
|
||||
final GameState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
final questHistory = state.progress.questHistory;
|
||||
|
||||
if (questHistory.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
l10n.noActiveQuests.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: questHistory.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final quest = questHistory[index];
|
||||
final isCurrentQuest =
|
||||
index == questHistory.length - 1 && !quest.isComplete;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isCurrentQuest
|
||||
? Icons.arrow_right
|
||||
: (quest.isComplete
|
||||
? Icons.check_box
|
||||
: Icons.check_box_outline_blank),
|
||||
size: 12,
|
||||
color: isCurrentQuest
|
||||
? RetroColors.gold
|
||||
: (quest.isComplete
|
||||
? RetroColors.expGreen
|
||||
: RetroColors.textDisabled),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
quest.caption,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: isCurrentQuest
|
||||
? RetroColors.gold
|
||||
: (quest.isComplete
|
||||
? RetroColors.textDisabled
|
||||
: RetroColors.textLight),
|
||||
decoration: quest.isComplete
|
||||
? TextDecoration.lineThrough
|
||||
: TextDecoration.none,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
|
||||
import 'package:asciineverdie/src/core/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/ascii_animation_type.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/monster_size.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';
|
||||
import 'package:asciineverdie/src/features/game/widgets/compact_status_bars.dart';
|
||||
|
||||
/// 모바일용 확장 애니메이션 패널
|
||||
///
|
||||
@@ -325,9 +325,23 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Expanded(child: _buildCompactHpBar()),
|
||||
Expanded(
|
||||
child: CompactHpBar(
|
||||
current: _currentHp,
|
||||
max: _currentHpMax,
|
||||
flashAnimation: _hpFlashAnimation,
|
||||
hpChange: _hpChange,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Expanded(child: _buildCompactMpBar()),
|
||||
Expanded(
|
||||
child: CompactMpBar(
|
||||
current: _currentMp,
|
||||
max: _currentMpMax,
|
||||
flashAnimation: _mpFlashAnimation,
|
||||
mpChange: _mpChange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -339,7 +353,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: switch ((shouldShowMonsterHp, combat)) {
|
||||
(true, final c?) => _buildMonsterHpBar(c),
|
||||
(true, final c?) => CompactMonsterHpBar(
|
||||
combat: c,
|
||||
monsterHpCurrent: _currentMonsterHp,
|
||||
monsterHpMax: _currentMonsterHpMax,
|
||||
monsterLevel: widget.monsterLevel,
|
||||
flashAnimation: _monsterFlashAnimation,
|
||||
monsterHpChange: _monsterHpChange,
|
||||
),
|
||||
_ => const SizedBox.shrink(),
|
||||
},
|
||||
),
|
||||
@@ -356,337 +377,6 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
);
|
||||
}
|
||||
|
||||
/// 컴팩트 HP 바 (숫자 오버레이)
|
||||
Widget _buildCompactHpBar() {
|
||||
final ratio = _currentHpMax > 0 ? _currentHp / _currentHpMax : 0.0;
|
||||
final isLow = ratio < 0.2 && ratio > 0;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _hpFlashAnimation,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// HP 바
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isLow
|
||||
? Colors.red.withValues(alpha: 0.2)
|
||||
: Colors.grey.shade800,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 라벨
|
||||
Container(
|
||||
width: 32,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
l10n.statHp,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 프로그레스 바 + 숫자 오버레이
|
||||
Expanded(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 프로그레스 바
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
right: Radius.circular(3),
|
||||
),
|
||||
child: SizedBox.expand(
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio.clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.red.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
isLow ? Colors.red : Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 숫자 오버레이 (바 중앙)
|
||||
Text(
|
||||
'$_currentHp/$_currentHpMax',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(alpha: 0.9),
|
||||
blurRadius: 2,
|
||||
),
|
||||
const Shadow(color: Colors.black, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 플로팅 변화량
|
||||
if (_hpChange != 0 && _hpFlashAnimation.value > 0.05)
|
||||
Positioned(
|
||||
right: 20,
|
||||
top: -8,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -10 * (1 - _hpFlashAnimation.value)),
|
||||
child: Opacity(
|
||||
opacity: _hpFlashAnimation.value,
|
||||
child: Text(
|
||||
_hpChange > 0 ? '+$_hpChange' : '$_hpChange',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _hpChange < 0 ? Colors.red : Colors.green,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black, blurRadius: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 컴팩트 MP 바 (숫자 오버레이)
|
||||
Widget _buildCompactMpBar() {
|
||||
final ratio = _currentMpMax > 0 ? _currentMp / _currentMpMax : 0.0;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _mpFlashAnimation,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade800,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
l10n.statMp,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 프로그레스 바 + 숫자 오버레이
|
||||
Expanded(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 프로그레스 바
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
right: Radius.circular(3),
|
||||
),
|
||||
child: SizedBox.expand(
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio.clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.blue.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
Colors.blue.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 숫자 오버레이 (바 중앙)
|
||||
Text(
|
||||
'$_currentMp/$_currentMpMax',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(alpha: 0.9),
|
||||
blurRadius: 2,
|
||||
),
|
||||
const Shadow(color: Colors.black, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (_mpChange != 0 && _mpFlashAnimation.value > 0.05)
|
||||
Positioned(
|
||||
right: 20,
|
||||
top: -8,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -10 * (1 - _mpFlashAnimation.value)),
|
||||
child: Opacity(
|
||||
opacity: _mpFlashAnimation.value,
|
||||
child: Text(
|
||||
_mpChange > 0 ? '+$_mpChange' : '$_mpChange',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _mpChange < 0 ? Colors.orange : Colors.cyan,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black, blurRadius: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 몬스터 HP 바 (전투 중)
|
||||
/// - HP바 중앙에 HP% 오버레이
|
||||
/// - 하단에 레벨.이름 표시
|
||||
Widget _buildMonsterHpBar(CombatState combat) {
|
||||
final max = _currentMonsterHpMax ?? 1;
|
||||
final current = _currentMonsterHp ?? 0;
|
||||
final ratio = max > 0 ? current / max : 0.0;
|
||||
final monsterName = combat.monsterStats.name;
|
||||
final monsterLevel = widget.monsterLevel ?? combat.monsterStats.level;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _monsterFlashAnimation,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// HP 바 (HP% 중앙 오버레이)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 2),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// HP 바
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: SizedBox.expand(
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio.clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.orange.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
valueColor: const AlwaysStoppedAnimation(
|
||||
Colors.orange,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// HP% 중앙 오버레이
|
||||
Text(
|
||||
'${(ratio * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(alpha: 0.8),
|
||||
blurRadius: 2,
|
||||
),
|
||||
const Shadow(
|
||||
color: Colors.black,
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 레벨.이름 표시
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
|
||||
child: Text(
|
||||
'Lv.$monsterLevel $monsterName',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 플로팅 데미지
|
||||
if (_monsterHpChange != 0 && _monsterFlashAnimation.value > 0.05)
|
||||
Positioned(
|
||||
right: 10,
|
||||
top: -10,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -10 * (1 - _monsterFlashAnimation.value)),
|
||||
child: Opacity(
|
||||
opacity: _monsterFlashAnimation.value,
|
||||
child: Text(
|
||||
_monsterHpChange > 0
|
||||
? '+$_monsterHpChange'
|
||||
: '$_monsterHpChange',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _monsterHpChange < 0
|
||||
? Colors.yellow
|
||||
: Colors.green,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black, blurRadius: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 속도 컨트롤 버튼 (태스크 프로그레스 바 우측)
|
||||
///
|
||||
/// - 5x/20x 토글 버튼 하나만 표시
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
||||
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.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/game_state.dart';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/features/game/widgets/retro_monster_hp_bar.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// HP/MP 바 위젯 (레트로 RPG 스타일)
|
||||
@@ -201,7 +202,17 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
|
||||
),
|
||||
|
||||
// 몬스터 HP 바 (전투 중일 때만)
|
||||
if (hasMonster) ...[const SizedBox(height: 8), _buildMonsterBar()],
|
||||
if (hasMonster) ...[
|
||||
const SizedBox(height: 8),
|
||||
RetroMonsterHpBar(
|
||||
monsterHpCurrent: widget.monsterHpCurrent!,
|
||||
monsterHpMax: widget.monsterHpMax!,
|
||||
monsterName: widget.monsterName,
|
||||
monsterLevel: widget.monsterLevel,
|
||||
flashAnimation: _monsterFlashAnimation,
|
||||
monsterHpChange: _monsterHpChange,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -378,150 +389,4 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
|
||||
);
|
||||
}
|
||||
|
||||
/// 몬스터 HP 바 (레트로 스타일)
|
||||
/// - HP바 중앙에 HP% 오버레이
|
||||
/// - 하단에 레벨.이름 표시
|
||||
Widget _buildMonsterBar() {
|
||||
final max = widget.monsterHpMax!;
|
||||
final ratio = max > 0 ? widget.monsterHpCurrent! / max : 0.0;
|
||||
const segmentCount = 10;
|
||||
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
|
||||
final levelPrefix = widget.monsterLevel != null
|
||||
? 'Lv.${widget.monsterLevel} '
|
||||
: '';
|
||||
final monsterName = widget.monsterName ?? '';
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _monsterFlashAnimation,
|
||||
builder: (context, child) {
|
||||
// 데미지 플래시 (몬스터는 항상 데미지를 받음)
|
||||
final flashColor = RetroColors.gold.withValues(
|
||||
alpha: _monsterFlashAnimation.value * 0.3,
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: _monsterFlashAnimation.value > 0.1
|
||||
? flashColor
|
||||
: RetroColors.panelBgLight.withValues(alpha: 0.5),
|
||||
border: Border.all(
|
||||
color: RetroColors.gold.withValues(alpha: 0.6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// HP 바 (HP% 중앙 오버레이)
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 세그먼트 HP 바
|
||||
Container(
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.hpRedDark.withValues(alpha: 0.3),
|
||||
border: Border.all(
|
||||
color: RetroColors.panelBorderOuter,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: List.generate(segmentCount, (index) {
|
||||
final isFilled = index < filledSegments;
|
||||
return Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isFilled
|
||||
? RetroColors.gold
|
||||
: RetroColors.panelBorderOuter.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
border: Border(
|
||||
right: index < segmentCount - 1
|
||||
? BorderSide(
|
||||
color: RetroColors.panelBorderOuter
|
||||
.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
// HP% 중앙 오버레이
|
||||
Text(
|
||||
'${(ratio * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.textLight,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(alpha: 0.8),
|
||||
blurRadius: 2,
|
||||
),
|
||||
const Shadow(color: Colors.black, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 레벨.이름 표시
|
||||
Text(
|
||||
'$levelPrefix$monsterName',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 플로팅 데미지 텍스트
|
||||
if (_monsterHpChange != 0 && _monsterFlashAnimation.value > 0.05)
|
||||
Positioned(
|
||||
right: 50,
|
||||
top: -8,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -12 * (1 - _monsterFlashAnimation.value)),
|
||||
child: Opacity(
|
||||
opacity: _monsterFlashAnimation.value,
|
||||
child: Text(
|
||||
_monsterHpChange > 0
|
||||
? '+$_monsterHpChange'
|
||||
: '$_monsterHpChange',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _monsterHpChange < 0
|
||||
? RetroColors.gold
|
||||
: RetroColors.expGreen,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black, blurRadius: 3),
|
||||
Shadow(color: Colors.black, blurRadius: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
522
lib/src/features/game/widgets/mobile_options_menu.dart
Normal file
522
lib/src/features/game/widgets/mobile_options_menu.dart
Normal file
@@ -0,0 +1,522 @@
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/dialogs/retro_confirm_dialog.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/dialogs/retro_select_dialog.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/dialogs/retro_sound_dialog.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/menu/retro_menu_widgets.dart';
|
||||
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
|
||||
|
||||
/// 모바일 옵션 메뉴 설정
|
||||
class MobileOptionsConfig {
|
||||
const MobileOptionsConfig({
|
||||
required this.isPaused,
|
||||
required this.speedMultiplier,
|
||||
required this.bgmVolume,
|
||||
required this.sfxVolume,
|
||||
required this.cheatsEnabled,
|
||||
required this.isPaidUser,
|
||||
required this.isSpeedBoostActive,
|
||||
required this.adSpeedMultiplier,
|
||||
required this.notificationService,
|
||||
required this.onPauseToggle,
|
||||
required this.onSpeedCycle,
|
||||
required this.onSave,
|
||||
required this.onExit,
|
||||
required this.onLanguageChange,
|
||||
required this.onDeleteSaveAndNewGame,
|
||||
this.onBgmVolumeChange,
|
||||
this.onSfxVolumeChange,
|
||||
this.onShowStatistics,
|
||||
this.onShowHelp,
|
||||
this.onCheatTask,
|
||||
this.onCheatQuest,
|
||||
this.onCheatPlot,
|
||||
this.onCreateTestCharacter,
|
||||
this.onSpeedBoostActivate,
|
||||
this.onSetSpeed,
|
||||
});
|
||||
|
||||
final bool isPaused;
|
||||
final int speedMultiplier;
|
||||
final double bgmVolume;
|
||||
final double sfxVolume;
|
||||
final bool cheatsEnabled;
|
||||
final bool isPaidUser;
|
||||
final bool isSpeedBoostActive;
|
||||
final int adSpeedMultiplier;
|
||||
final NotificationService notificationService;
|
||||
final VoidCallback onPauseToggle;
|
||||
final VoidCallback onSpeedCycle;
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onExit;
|
||||
final void Function(String locale) onLanguageChange;
|
||||
final VoidCallback onDeleteSaveAndNewGame;
|
||||
final void Function(double volume)? onBgmVolumeChange;
|
||||
final void Function(double volume)? onSfxVolumeChange;
|
||||
final VoidCallback? onShowStatistics;
|
||||
final VoidCallback? onShowHelp;
|
||||
final VoidCallback? onCheatTask;
|
||||
final VoidCallback? onCheatQuest;
|
||||
final VoidCallback? onCheatPlot;
|
||||
final Future<void> Function()? onCreateTestCharacter;
|
||||
final VoidCallback? onSpeedBoostActivate;
|
||||
final void Function(int speed)? onSetSpeed;
|
||||
}
|
||||
|
||||
/// 모바일 옵션 메뉴 표시
|
||||
void showMobileOptionsMenu(BuildContext context, MobileOptionsConfig config) {
|
||||
final background = RetroColors.backgroundOf(context);
|
||||
final border = RetroColors.borderOf(context);
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.75,
|
||||
),
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
border: Border.all(color: border, width: 2),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 핸들 바
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Container(width: 60, height: 4, color: border),
|
||||
),
|
||||
// 헤더
|
||||
const _OptionsHeader(),
|
||||
// 메뉴 목록
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: _OptionsMenuBody(config: config),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _OptionsHeader extends StatelessWidget {
|
||||
const _OptionsHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final gold = RetroColors.goldOf(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.panelBgOf(context),
|
||||
border: Border(bottom: BorderSide(color: gold, width: 2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.settings, color: gold, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
L10n.of(context).optionsTitle,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: gold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
RetroIconButton(
|
||||
icon: Icons.close,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
size: 28,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OptionsMenuBody extends StatelessWidget {
|
||||
const _OptionsMenuBody({required this.config});
|
||||
|
||||
final MobileOptionsConfig config;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final gold = RetroColors.goldOf(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// === 게임 제어 ===
|
||||
RetroMenuSection(title: L10n.of(context).controlSection),
|
||||
const SizedBox(height: 8),
|
||||
_buildPauseItem(context),
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: Icons.speed,
|
||||
iconColor: gold,
|
||||
label: l10n.menuSpeed.toUpperCase(),
|
||||
trailing: _buildSpeedSelector(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// === 정보 ===
|
||||
RetroMenuSection(title: L10n.of(context).infoSection),
|
||||
const SizedBox(height: 8),
|
||||
if (config.onShowStatistics != null)
|
||||
RetroMenuItem(
|
||||
icon: Icons.bar_chart,
|
||||
iconColor: RetroColors.mpOf(context),
|
||||
label: l10n.uiStatistics.toUpperCase(),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
config.onShowStatistics?.call();
|
||||
},
|
||||
),
|
||||
if (config.onShowHelp != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: Icons.help_outline,
|
||||
iconColor: RetroColors.expOf(context),
|
||||
label: l10n.uiHelp.toUpperCase(),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
config.onShowHelp?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// === 설정 ===
|
||||
RetroMenuSection(title: L10n.of(context).settingsSection),
|
||||
const SizedBox(height: 8),
|
||||
_buildLanguageItem(context),
|
||||
if (config.onBgmVolumeChange != null ||
|
||||
config.onSfxVolumeChange != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildSoundItem(context),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// === 저장/종료 ===
|
||||
RetroMenuSection(title: L10n.of(context).saveExitSection),
|
||||
const SizedBox(height: 8),
|
||||
_buildSaveItem(context),
|
||||
const SizedBox(height: 8),
|
||||
_buildNewGameItem(context),
|
||||
const SizedBox(height: 8),
|
||||
_buildExitItem(context),
|
||||
|
||||
// === 치트 섹션 ===
|
||||
if (config.cheatsEnabled) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildCheatSection(context),
|
||||
],
|
||||
|
||||
// === 디버그 도구 섹션 ===
|
||||
if (kDebugMode && config.onCreateTestCharacter != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildDebugSection(context),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPauseItem(BuildContext context) {
|
||||
return RetroMenuItem(
|
||||
icon: config.isPaused ? Icons.play_arrow : Icons.pause,
|
||||
iconColor: config.isPaused
|
||||
? RetroColors.expOf(context)
|
||||
: RetroColors.warningOf(context),
|
||||
label: config.isPaused
|
||||
? l10n.menuResume.toUpperCase()
|
||||
: l10n.menuPause.toUpperCase(),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
config.onPauseToggle();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpeedSelector(BuildContext context) {
|
||||
return RetroSpeedChip(
|
||||
speed: config.adSpeedMultiplier,
|
||||
isSelected: config.isSpeedBoostActive,
|
||||
isAdBased: !config.isSpeedBoostActive && !config.isPaidUser,
|
||||
isDisabled: config.isSpeedBoostActive,
|
||||
onTap: () {
|
||||
if (!config.isSpeedBoostActive) {
|
||||
config.onSpeedBoostActivate?.call();
|
||||
}
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLanguageItem(BuildContext context) {
|
||||
final currentLang = _getCurrentLanguageName();
|
||||
return RetroMenuItem(
|
||||
icon: Icons.language,
|
||||
iconColor: RetroColors.mpOf(context),
|
||||
label: l10n.menuLanguage.toUpperCase(),
|
||||
value: currentLang,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showLanguageDialog(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSoundItem(BuildContext context) {
|
||||
final bgmPercent = (config.bgmVolume * 100).round();
|
||||
final sfxPercent = (config.sfxVolume * 100).round();
|
||||
final status = (bgmPercent == 0 && sfxPercent == 0)
|
||||
? l10n.uiSoundOff
|
||||
: 'BGM $bgmPercent% / SFX $sfxPercent%';
|
||||
|
||||
return RetroMenuItem(
|
||||
icon: config.bgmVolume == 0 && config.sfxVolume == 0
|
||||
? Icons.volume_off
|
||||
: Icons.volume_up,
|
||||
iconColor: RetroColors.textMutedOf(context),
|
||||
label: l10n.uiSound.toUpperCase(),
|
||||
value: status,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showSoundDialog(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSaveItem(BuildContext context) {
|
||||
return RetroMenuItem(
|
||||
icon: Icons.save,
|
||||
iconColor: RetroColors.mpOf(context),
|
||||
label: l10n.menuSave.toUpperCase(),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
config.onSave();
|
||||
config.notificationService.showGameSaved(l10n.menuSaved);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNewGameItem(BuildContext context) {
|
||||
return RetroMenuItem(
|
||||
icon: Icons.refresh,
|
||||
iconColor: RetroColors.warningOf(context),
|
||||
label: l10n.menuNewGame.toUpperCase(),
|
||||
subtitle: l10n.menuDeleteSave,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showDeleteConfirmDialog(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExitItem(BuildContext context) {
|
||||
return RetroMenuItem(
|
||||
icon: Icons.exit_to_app,
|
||||
iconColor: RetroColors.hpOf(context),
|
||||
label: L10n.of(context).exitGame.toUpperCase(),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
config.onExit();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCheatSection(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
RetroMenuSection(
|
||||
title: L10n.of(context).debugCheatsTitle,
|
||||
color: RetroColors.hpOf(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: Icons.fast_forward,
|
||||
iconColor: RetroColors.hpOf(context),
|
||||
label: L10n.of(context).debugSkipTask,
|
||||
subtitle: L10n.of(context).debugSkipTaskDesc,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
config.onCheatTask?.call();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: Icons.skip_next,
|
||||
iconColor: RetroColors.hpOf(context),
|
||||
label: L10n.of(context).debugSkipQuest,
|
||||
subtitle: L10n.of(context).debugSkipQuestDesc,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
config.onCheatQuest?.call();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: Icons.double_arrow,
|
||||
iconColor: RetroColors.hpOf(context),
|
||||
label: L10n.of(context).debugSkipAct,
|
||||
subtitle: L10n.of(context).debugSkipActDesc,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
config.onCheatPlot?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDebugSection(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
RetroMenuSection(
|
||||
title: L10n.of(context).debugToolsTitle,
|
||||
color: RetroColors.warningOf(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RetroMenuItem(
|
||||
icon: Icons.science,
|
||||
iconColor: RetroColors.warningOf(context),
|
||||
label: L10n.of(context).debugCreateTestCharacter,
|
||||
subtitle: L10n.of(context).debugCreateTestCharacterDesc,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showTestCharacterDialog(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getCurrentLanguageName() {
|
||||
final locale = l10n.currentGameLocale;
|
||||
if (locale == 'ko') return l10n.languageKorean;
|
||||
if (locale == 'ja') return l10n.languageJapanese;
|
||||
return l10n.languageEnglish;
|
||||
}
|
||||
|
||||
void _showLanguageDialog(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => RetroSelectDialog(
|
||||
title: l10n.menuLanguage.toUpperCase(),
|
||||
children: [
|
||||
_buildLangOption(
|
||||
context,
|
||||
'en',
|
||||
l10n.languageEnglish,
|
||||
'\u{1F1FA}\u{1F1F8}',
|
||||
),
|
||||
_buildLangOption(
|
||||
context,
|
||||
'ko',
|
||||
l10n.languageKorean,
|
||||
'\u{1F1F0}\u{1F1F7}',
|
||||
),
|
||||
_buildLangOption(
|
||||
context,
|
||||
'ja',
|
||||
l10n.languageJapanese,
|
||||
'\u{1F1EF}\u{1F1F5}',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLangOption(
|
||||
BuildContext context,
|
||||
String locale,
|
||||
String label,
|
||||
String flag,
|
||||
) {
|
||||
final isSelected = l10n.currentGameLocale == locale;
|
||||
return RetroOptionItem(
|
||||
label: label.toUpperCase(),
|
||||
prefix: flag,
|
||||
isSelected: isSelected,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
config.onLanguageChange(locale);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showSoundDialog(BuildContext context) {
|
||||
var bgmVolume = config.bgmVolume;
|
||||
var sfxVolume = config.sfxVolume;
|
||||
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => RetroSoundDialog(
|
||||
bgmVolume: bgmVolume,
|
||||
sfxVolume: sfxVolume,
|
||||
onBgmChanged: (double value) {
|
||||
setDialogState(() => bgmVolume = value);
|
||||
config.onBgmVolumeChange?.call(value);
|
||||
},
|
||||
onSfxChanged: (double value) {
|
||||
setDialogState(() => sfxVolume = value);
|
||||
config.onSfxVolumeChange?.call(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteConfirmDialog(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => RetroConfirmDialog(
|
||||
title: l10n.confirmDeleteTitle.toUpperCase(),
|
||||
message: l10n.confirmDeleteMessage,
|
||||
confirmText: l10n.buttonConfirm.toUpperCase(),
|
||||
cancelText: l10n.buttonCancel.toUpperCase(),
|
||||
onConfirm: () {
|
||||
Navigator.pop(context);
|
||||
config.onDeleteSaveAndNewGame();
|
||||
},
|
||||
onCancel: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showTestCharacterDialog(BuildContext context) {
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => RetroConfirmDialog(
|
||||
title: L10n.of(context).debugCreateTestCharacterTitle,
|
||||
message: L10n.of(context).debugCreateTestCharacterMessage,
|
||||
confirmText: L10n.of(context).createButton,
|
||||
cancelText: L10n.of(context).cancel.toUpperCase(),
|
||||
onConfirm: () {
|
||||
Navigator.of(context).pop(true);
|
||||
config.onCreateTestCharacter?.call();
|
||||
},
|
||||
onCancel: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
@@ -172,7 +173,7 @@ class _NotificationCard extends StatelessWidget {
|
||||
// 타입 표시
|
||||
Expanded(
|
||||
child: Text(
|
||||
_getTypeLabel(notification.type),
|
||||
_getTypeLabel(context, notification.type),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
@@ -280,18 +281,19 @@ class _NotificationCard extends StatelessWidget {
|
||||
};
|
||||
}
|
||||
|
||||
/// 알림 타입 라벨
|
||||
String _getTypeLabel(NotificationType type) {
|
||||
/// 알림 타입 라벨 (l10n)
|
||||
String _getTypeLabel(BuildContext context, NotificationType type) {
|
||||
final l10n = L10n.of(context);
|
||||
return switch (type) {
|
||||
NotificationType.levelUp => 'LEVEL UP',
|
||||
NotificationType.questComplete => 'QUEST DONE',
|
||||
NotificationType.actComplete => 'ACT CLEAR',
|
||||
NotificationType.newSpell => 'NEW SPELL',
|
||||
NotificationType.newEquipment => 'NEW ITEM',
|
||||
NotificationType.bossDefeat => 'BOSS SLAIN',
|
||||
NotificationType.gameSaved => 'SAVED',
|
||||
NotificationType.info => 'INFO',
|
||||
NotificationType.warning => 'WARNING',
|
||||
NotificationType.levelUp => l10n.notifyLevelUpLabel,
|
||||
NotificationType.questComplete => l10n.notifyQuestDoneLabel,
|
||||
NotificationType.actComplete => l10n.notifyActClearLabel,
|
||||
NotificationType.newSpell => l10n.notifyNewSpellLabel,
|
||||
NotificationType.newEquipment => l10n.notifyNewItemLabel,
|
||||
NotificationType.bossDefeat => l10n.notifyBossSlainLabel,
|
||||
NotificationType.gameSaved => l10n.notifySavedLabel,
|
||||
NotificationType.info => l10n.notifyInfoLabel,
|
||||
NotificationType.warning => l10n.notifyWarningLabel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
166
lib/src/features/game/widgets/retro_monster_hp_bar.dart
Normal file
166
lib/src/features/game/widgets/retro_monster_hp_bar.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 몬스터 HP 바 (레트로 세그먼트 스타일)
|
||||
///
|
||||
/// 데스크탑 전용 세그먼트 바. HP% 중앙 오버레이 + 레벨.이름 표시.
|
||||
class RetroMonsterHpBar extends StatelessWidget {
|
||||
const RetroMonsterHpBar({
|
||||
super.key,
|
||||
required this.monsterHpCurrent,
|
||||
required this.monsterHpMax,
|
||||
required this.monsterName,
|
||||
required this.monsterLevel,
|
||||
required this.flashAnimation,
|
||||
required this.monsterHpChange,
|
||||
});
|
||||
|
||||
final int monsterHpCurrent;
|
||||
final int monsterHpMax;
|
||||
final String? monsterName;
|
||||
final int? monsterLevel;
|
||||
final Animation<double> flashAnimation;
|
||||
final int monsterHpChange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ratio = monsterHpMax > 0 ? monsterHpCurrent / monsterHpMax : 0.0;
|
||||
const segmentCount = 10;
|
||||
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
|
||||
final levelPrefix = monsterLevel != null ? 'Lv.$monsterLevel ' : '';
|
||||
final name = monsterName ?? '';
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: flashAnimation,
|
||||
builder: (context, child) {
|
||||
final flashColor = RetroColors.gold.withValues(
|
||||
alpha: flashAnimation.value * 0.3,
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: flashAnimation.value > 0.1
|
||||
? flashColor
|
||||
: RetroColors.panelBgLight.withValues(alpha: 0.5),
|
||||
border: Border.all(
|
||||
color: RetroColors.gold.withValues(alpha: 0.6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// HP 바 (HP% 중앙 오버레이)
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
_buildSegmentBar(segmentCount, filledSegments),
|
||||
// HP% 중앙 오버레이
|
||||
Text(
|
||||
'${(ratio * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: RetroColors.textLight,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(alpha: 0.8),
|
||||
blurRadius: 2,
|
||||
),
|
||||
const Shadow(color: Colors.black, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 레벨.이름 표시
|
||||
Text(
|
||||
'$levelPrefix$name',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 플로팅 데미지 텍스트
|
||||
if (monsterHpChange != 0 && flashAnimation.value > 0.05)
|
||||
Positioned(
|
||||
right: 50,
|
||||
top: -8,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -12 * (1 - flashAnimation.value)),
|
||||
child: Opacity(
|
||||
opacity: flashAnimation.value,
|
||||
child: Text(
|
||||
monsterHpChange > 0
|
||||
? '+$monsterHpChange'
|
||||
: '$monsterHpChange',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: monsterHpChange < 0
|
||||
? RetroColors.gold
|
||||
: RetroColors.expGreen,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black, blurRadius: 3),
|
||||
Shadow(color: Colors.black, blurRadius: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 세그먼트 HP 바
|
||||
Widget _buildSegmentBar(int segmentCount, int filledSegments) {
|
||||
return Container(
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.hpRedDark.withValues(alpha: 0.3),
|
||||
border: Border.all(color: RetroColors.panelBorderOuter, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
children: List.generate(segmentCount, (index) {
|
||||
final isFilled = index < filledSegments;
|
||||
return Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isFilled
|
||||
? RetroColors.gold
|
||||
: RetroColors.panelBorderOuter.withValues(alpha: 0.3),
|
||||
border: Border(
|
||||
right: index < segmentCount - 1
|
||||
? BorderSide(
|
||||
color: RetroColors.panelBorderOuter.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
width: 1,
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_statistics.dart';
|
||||
import 'package:asciineverdie/src/shared/widgets/retro_dialog.dart';
|
||||
|
||||
@@ -52,34 +53,19 @@ class _StatisticsDialogState extends State<StatisticsDialog>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isKorean = Localizations.localeOf(context).languageCode == 'ko';
|
||||
final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
|
||||
|
||||
final title = isKorean
|
||||
? '통계'
|
||||
: isJapanese
|
||||
? '統計'
|
||||
: 'Statistics';
|
||||
|
||||
final tabs = isKorean
|
||||
? ['세션', '누적']
|
||||
: isJapanese
|
||||
? ['セッション', '累積']
|
||||
: ['Session', 'Total'];
|
||||
final l10n = L10n.of(context);
|
||||
|
||||
return RetroDialog(
|
||||
title: title,
|
||||
title: l10n.statsStatistics,
|
||||
titleIcon: '📊',
|
||||
maxWidth: 420,
|
||||
maxHeight: 520,
|
||||
// accentColor: 테마에서 자동 결정 (goldOf)
|
||||
child: Column(
|
||||
children: [
|
||||
// 탭 바
|
||||
RetroTabBar(
|
||||
controller: _tabController,
|
||||
tabs: tabs,
|
||||
// accentColor: 테마에서 자동 결정 (goldOf)
|
||||
tabs: [l10n.statsSession, l10n.statsAccumulated],
|
||||
),
|
||||
// 탭 내용
|
||||
Expanded(
|
||||
@@ -105,198 +91,109 @@ class _SessionStatisticsView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isKorean = Localizations.localeOf(context).languageCode == 'ko';
|
||||
final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
|
||||
final l10n = L10n.of(context);
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
_StatSection(
|
||||
title: isKorean
|
||||
? '전투'
|
||||
: isJapanese
|
||||
? '戦闘'
|
||||
: 'Combat',
|
||||
title: l10n.statsCombat,
|
||||
icon: '⚔',
|
||||
items: [
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '플레이 시간'
|
||||
: isJapanese
|
||||
? 'プレイ時間'
|
||||
: 'Play Time',
|
||||
label: l10n.statsPlayTime,
|
||||
value: stats.formattedPlayTime,
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '처치한 몬스터'
|
||||
: isJapanese
|
||||
? '倒したモンスター'
|
||||
: 'Monsters Killed',
|
||||
label: l10n.statsMonstersKilled,
|
||||
value: _formatNumber(stats.monstersKilled),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '보스 처치'
|
||||
: isJapanese
|
||||
? 'ボス討伐'
|
||||
: 'Bosses Defeated',
|
||||
label: l10n.statsBossesDefeated,
|
||||
value: _formatNumber(stats.bossesDefeated),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '사망 횟수'
|
||||
: isJapanese
|
||||
? '死亡回数'
|
||||
: 'Deaths',
|
||||
label: l10n.statsDeaths,
|
||||
value: _formatNumber(stats.deathCount),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StatSection(
|
||||
title: isKorean
|
||||
? '데미지'
|
||||
: isJapanese
|
||||
? 'ダメージ'
|
||||
: 'Damage',
|
||||
title: l10n.statsDamage,
|
||||
icon: '⚡',
|
||||
items: [
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '입힌 데미지'
|
||||
: isJapanese
|
||||
? '与えたダメージ'
|
||||
: 'Damage Dealt',
|
||||
label: l10n.statsDamageDealt,
|
||||
value: _formatNumber(stats.totalDamageDealt),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '받은 데미지'
|
||||
: isJapanese
|
||||
? '受けたダメージ'
|
||||
: 'Damage Taken',
|
||||
label: l10n.statsDamageTaken,
|
||||
value: _formatNumber(stats.totalDamageTaken),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '평균 DPS'
|
||||
: isJapanese
|
||||
? '平均DPS'
|
||||
: 'Average DPS',
|
||||
label: l10n.statsAverageDps,
|
||||
value: stats.averageDps.toStringAsFixed(1),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StatSection(
|
||||
title: isKorean
|
||||
? '스킬'
|
||||
: isJapanese
|
||||
? 'スキル'
|
||||
: 'Skills',
|
||||
title: l10n.statsSkills,
|
||||
icon: '✧',
|
||||
items: [
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '스킬 사용'
|
||||
: isJapanese
|
||||
? 'スキル使用'
|
||||
: 'Skills Used',
|
||||
label: l10n.statsSkillsUsed,
|
||||
value: _formatNumber(stats.skillsUsed),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '크리티컬 히트'
|
||||
: isJapanese
|
||||
? 'クリティカルヒット'
|
||||
: 'Critical Hits',
|
||||
label: l10n.statsCriticalHits,
|
||||
value: _formatNumber(stats.criticalHits),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '최대 연속 크리티컬'
|
||||
: isJapanese
|
||||
? '最大連続クリティカル'
|
||||
: 'Max Critical Streak',
|
||||
label: l10n.statsMaxCriticalStreak,
|
||||
value: _formatNumber(stats.maxCriticalStreak),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '크리티컬 비율'
|
||||
: isJapanese
|
||||
? 'クリティカル率'
|
||||
: 'Critical Rate',
|
||||
label: l10n.statsCriticalRate,
|
||||
value: '${(stats.criticalRate * 100).toStringAsFixed(1)}%',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StatSection(
|
||||
title: isKorean
|
||||
? '경제'
|
||||
: isJapanese
|
||||
? '経済'
|
||||
: 'Economy',
|
||||
title: l10n.statsEconomy,
|
||||
icon: '💰',
|
||||
items: [
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '획득 골드'
|
||||
: isJapanese
|
||||
? '獲得ゴールド'
|
||||
: 'Gold Earned',
|
||||
label: l10n.statsGoldEarned,
|
||||
value: _formatNumber(stats.goldEarned),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '소비 골드'
|
||||
: isJapanese
|
||||
? '消費ゴールド'
|
||||
: 'Gold Spent',
|
||||
label: l10n.statsGoldSpent,
|
||||
value: _formatNumber(stats.goldSpent),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '판매 아이템'
|
||||
: isJapanese
|
||||
? '売却アイテム'
|
||||
: 'Items Sold',
|
||||
label: l10n.statsItemsSold,
|
||||
value: _formatNumber(stats.itemsSold),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '물약 사용'
|
||||
: isJapanese
|
||||
? 'ポーション使用'
|
||||
: 'Potions Used',
|
||||
label: l10n.statsPotionsUsed,
|
||||
value: _formatNumber(stats.potionsUsed),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StatSection(
|
||||
title: isKorean
|
||||
? '진행'
|
||||
: isJapanese
|
||||
? '進行'
|
||||
: 'Progress',
|
||||
title: l10n.statsProgress,
|
||||
icon: '↑',
|
||||
items: [
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '레벨업'
|
||||
: isJapanese
|
||||
? 'レベルアップ'
|
||||
: 'Level Ups',
|
||||
label: l10n.statsLevelUps,
|
||||
value: _formatNumber(stats.levelUps),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '완료한 퀘스트'
|
||||
: isJapanese
|
||||
? '完了したクエスト'
|
||||
: 'Quests Completed',
|
||||
label: l10n.statsQuestsCompleted,
|
||||
value: _formatNumber(stats.questsCompleted),
|
||||
),
|
||||
],
|
||||
@@ -314,44 +211,27 @@ class _CumulativeStatisticsView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isKorean = Localizations.localeOf(context).languageCode == 'ko';
|
||||
final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
|
||||
final l10n = L10n.of(context);
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
_StatSection(
|
||||
title: isKorean
|
||||
? '기록'
|
||||
: isJapanese
|
||||
? '記録'
|
||||
: 'Records',
|
||||
title: l10n.statsRecords,
|
||||
icon: '🏆',
|
||||
items: [
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '최고 레벨'
|
||||
: isJapanese
|
||||
? '最高レベル'
|
||||
: 'Highest Level',
|
||||
label: l10n.statsHighestLevel,
|
||||
value: _formatNumber(stats.highestLevel),
|
||||
highlight: true,
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '최대 보유 골드'
|
||||
: isJapanese
|
||||
? '最大所持ゴールド'
|
||||
: 'Highest Gold Held',
|
||||
label: l10n.statsHighestGoldHeld,
|
||||
value: _formatNumber(stats.highestGoldHeld),
|
||||
highlight: true,
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '최고 연속 크리티컬'
|
||||
: isJapanese
|
||||
? '最高連続クリティカル'
|
||||
: 'Best Critical Streak',
|
||||
label: l10n.statsBestCriticalStreak,
|
||||
value: _formatNumber(stats.bestCriticalStreak),
|
||||
highlight: true,
|
||||
),
|
||||
@@ -359,191 +239,103 @@ class _CumulativeStatisticsView extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StatSection(
|
||||
title: isKorean
|
||||
? '총 플레이'
|
||||
: isJapanese
|
||||
? '総プレイ'
|
||||
: 'Total Play',
|
||||
title: l10n.statsTotalPlay,
|
||||
icon: '⏱',
|
||||
items: [
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '총 플레이 시간'
|
||||
: isJapanese
|
||||
? '総プレイ時間'
|
||||
: 'Total Play Time',
|
||||
label: l10n.statsTotalPlayTime,
|
||||
value: stats.formattedTotalPlayTime,
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '시작한 게임'
|
||||
: isJapanese
|
||||
? '開始したゲーム'
|
||||
: 'Games Started',
|
||||
label: l10n.statsGamesStarted,
|
||||
value: _formatNumber(stats.gamesStarted),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '클리어한 게임'
|
||||
: isJapanese
|
||||
? 'クリアしたゲーム'
|
||||
: 'Games Completed',
|
||||
label: l10n.statsGamesCompleted,
|
||||
value: _formatNumber(stats.gamesCompleted),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '클리어율'
|
||||
: isJapanese
|
||||
? 'クリア率'
|
||||
: 'Completion Rate',
|
||||
label: l10n.statsCompletionRate,
|
||||
value: '${(stats.completionRate * 100).toStringAsFixed(1)}%',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StatSection(
|
||||
title: isKorean
|
||||
? '총 전투'
|
||||
: isJapanese
|
||||
? '総戦闘'
|
||||
: 'Total Combat',
|
||||
title: l10n.statsTotalCombat,
|
||||
icon: '⚔',
|
||||
items: [
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '처치한 몬스터'
|
||||
: isJapanese
|
||||
? '倒したモンスター'
|
||||
: 'Monsters Killed',
|
||||
label: l10n.statsMonstersKilled,
|
||||
value: _formatNumber(stats.totalMonstersKilled),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '보스 처치'
|
||||
: isJapanese
|
||||
? 'ボス討伐'
|
||||
: 'Bosses Defeated',
|
||||
label: l10n.statsBossesDefeated,
|
||||
value: _formatNumber(stats.totalBossesDefeated),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '총 사망'
|
||||
: isJapanese
|
||||
? '総死亡'
|
||||
: 'Total Deaths',
|
||||
label: l10n.statsTotalDeaths,
|
||||
value: _formatNumber(stats.totalDeaths),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '총 레벨업'
|
||||
: isJapanese
|
||||
? '総レベルアップ'
|
||||
: 'Total Level Ups',
|
||||
label: l10n.statsTotalLevelUps,
|
||||
value: _formatNumber(stats.totalLevelUps),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StatSection(
|
||||
title: isKorean
|
||||
? '총 데미지'
|
||||
: isJapanese
|
||||
? '総ダメージ'
|
||||
: 'Total Damage',
|
||||
title: l10n.statsTotalDamage,
|
||||
icon: '⚡',
|
||||
items: [
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '입힌 데미지'
|
||||
: isJapanese
|
||||
? '与えたダメージ'
|
||||
: 'Damage Dealt',
|
||||
label: l10n.statsDamageDealt,
|
||||
value: _formatNumber(stats.totalDamageDealt),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '받은 데미지'
|
||||
: isJapanese
|
||||
? '受けたダメージ'
|
||||
: 'Damage Taken',
|
||||
label: l10n.statsDamageTaken,
|
||||
value: _formatNumber(stats.totalDamageTaken),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StatSection(
|
||||
title: isKorean
|
||||
? '총 스킬'
|
||||
: isJapanese
|
||||
? '総スキル'
|
||||
: 'Total Skills',
|
||||
title: l10n.statsTotalSkills,
|
||||
icon: '✧',
|
||||
items: [
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '스킬 사용'
|
||||
: isJapanese
|
||||
? 'スキル使用'
|
||||
: 'Skills Used',
|
||||
label: l10n.statsSkillsUsed,
|
||||
value: _formatNumber(stats.totalSkillsUsed),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '크리티컬 히트'
|
||||
: isJapanese
|
||||
? 'クリティカルヒット'
|
||||
: 'Critical Hits',
|
||||
label: l10n.statsCriticalHits,
|
||||
value: _formatNumber(stats.totalCriticalHits),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_StatSection(
|
||||
title: isKorean
|
||||
? '총 경제'
|
||||
: isJapanese
|
||||
? '総経済'
|
||||
: 'Total Economy',
|
||||
title: l10n.statsTotalEconomy,
|
||||
icon: '💰',
|
||||
items: [
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '획득 골드'
|
||||
: isJapanese
|
||||
? '獲得ゴールド'
|
||||
: 'Gold Earned',
|
||||
label: l10n.statsGoldEarned,
|
||||
value: _formatNumber(stats.totalGoldEarned),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '소비 골드'
|
||||
: isJapanese
|
||||
? '消費ゴールド'
|
||||
: 'Gold Spent',
|
||||
label: l10n.statsGoldSpent,
|
||||
value: _formatNumber(stats.totalGoldSpent),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '판매 아이템'
|
||||
: isJapanese
|
||||
? '売却アイテム'
|
||||
: 'Items Sold',
|
||||
label: l10n.statsItemsSold,
|
||||
value: _formatNumber(stats.totalItemsSold),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '물약 사용'
|
||||
: isJapanese
|
||||
? 'ポーション使用'
|
||||
: 'Potions Used',
|
||||
label: l10n.statsPotionsUsed,
|
||||
value: _formatNumber(stats.totalPotionsUsed),
|
||||
),
|
||||
_StatItem(
|
||||
label: isKorean
|
||||
? '완료 퀘스트'
|
||||
: isJapanese
|
||||
? '完了クエスト'
|
||||
: 'Quests Completed',
|
||||
label: l10n.statsQuestsCompleted,
|
||||
value: _formatNumber(stats.totalQuestsCompleted),
|
||||
),
|
||||
],
|
||||
@@ -593,7 +385,6 @@ class _StatItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// highlightColor: 테마에서 자동 결정 (goldOf)
|
||||
return RetroStatRow(label: label, value: value, highlight: highlight);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
|
||||
import 'package:asciineverdie/src/core/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/ascii_animation_type.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/monster_size.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';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/features/hall_of_fame/hero_detail_dialog.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.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/hall_of_fame.dart';
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/core/model/class_traits.dart';
|
||||
import 'package:asciineverdie/src/core/model/race_traits.dart' show StatType;
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/animation/character_frames.dart';
|
||||
import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/character_frames.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/race_character_frames.dart';
|
||||
|
||||
/// 종족 미리보기 위젯
|
||||
///
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/core/model/race_traits.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
|
||||
|
||||
381
lib/src/features/settings/retro_settings_widgets.dart
Normal file
381
lib/src/features/settings/retro_settings_widgets.dart
Normal file
@@ -0,0 +1,381 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
|
||||
|
||||
/// 설정 화면에서 사용하는 레트로 스타일 서브 위젯들
|
||||
|
||||
/// 섹션 타이틀
|
||||
class RetroSectionTitle extends StatelessWidget {
|
||||
const RetroSectionTitle({super.key, required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(width: 4, height: 14, color: RetroColors.goldOf(context)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: RetroColors.goldOf(context),
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 선택 가능한 아이템
|
||||
class RetroSelectableItem extends StatelessWidget {
|
||||
const RetroSelectableItem({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? RetroColors.goldOf(context).withValues(alpha: 0.15)
|
||||
: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? RetroColors.goldOf(context)
|
||||
: RetroColors.borderOf(context),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 볼륨 슬라이더
|
||||
class RetroVolumeSlider extends StatelessWidget {
|
||||
const RetroVolumeSlider({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final double value;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final percentage = (value * 100).round();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
value == 0 ? Icons.volume_off : icon,
|
||||
size: 14,
|
||||
color: RetroColors.goldOf(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'$percentage%',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: RetroColors.goldOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
RetroSlider(value: value, onChanged: onChanged),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 레트로 스타일 슬라이더
|
||||
class RetroSlider extends StatelessWidget {
|
||||
const RetroSlider({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final double value;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 8,
|
||||
activeTrackColor: RetroColors.goldOf(context),
|
||||
inactiveTrackColor: RetroColors.borderOf(context),
|
||||
thumbColor: RetroColors.goldLightOf(context),
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
|
||||
overlayColor: RetroColors.goldOf(context).withValues(alpha: 0.2),
|
||||
trackShape: const RectangularSliderTrackShape(),
|
||||
),
|
||||
child: Slider(value: value, onChanged: onChanged, divisions: 10),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 디버그 토글
|
||||
class RetroDebugToggle extends StatelessWidget {
|
||||
const RetroDebugToggle({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.description,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String description;
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: RetroColors.textPrimaryOf(context)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
color: RetroColors.textMutedOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
RetroToggle(value: value, onChanged: onChanged),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 레트로 스타일 토글
|
||||
class RetroToggle extends StatelessWidget {
|
||||
const RetroToggle({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(!value),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: value
|
||||
? RetroColors.goldOf(context).withValues(alpha: 0.3)
|
||||
: RetroColors.borderOf(context).withValues(alpha: 0.3),
|
||||
border: Border.all(
|
||||
color: value
|
||||
? RetroColors.goldOf(context)
|
||||
: RetroColors.borderOf(context),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: AnimatedAlign(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
margin: const EdgeInsets.all(1),
|
||||
color: value
|
||||
? RetroColors.goldOf(context)
|
||||
: RetroColors.textMutedOf(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 레트로 스타일 칩
|
||||
class RetroChip extends StatelessWidget {
|
||||
const RetroChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? RetroColors.goldOf(context).withValues(alpha: 0.2)
|
||||
: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? RetroColors.goldOf(context)
|
||||
: RetroColors.borderOf(context),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: isSelected
|
||||
? RetroColors.goldOf(context)
|
||||
: RetroColors.textMutedOf(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 레트로 스타일 확인 다이얼로그
|
||||
class RetroConfirmDialog extends StatelessWidget {
|
||||
const RetroConfirmDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.confirmText,
|
||||
required this.cancelText,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String message;
|
||||
final String confirmText;
|
||||
final String cancelText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.backgroundOf(context),
|
||||
border: Border.all(color: RetroColors.goldOf(context), width: 3),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 타이틀
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: RetroColors.goldOf(context).withValues(alpha: 0.2),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: RetroColors.goldOf(context),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// 메시지
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
height: 1.8,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// 버튼
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RetroTextButton(
|
||||
text: cancelText,
|
||||
isPrimary: false,
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: RetroTextButton(
|
||||
text: confirmText,
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:asciineverdie/src/core/engine/debug_settings_service.dart';
|
||||
import 'package:asciineverdie/src/core/storage/settings_repository.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
|
||||
import 'package:asciineverdie/src/features/settings/retro_settings_widgets.dart';
|
||||
|
||||
/// 통합 설정 화면 (레트로 스타일)
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
@@ -133,20 +134,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
// 언어 설정
|
||||
_RetroSectionTitle(title: game_l10n.uiLanguage),
|
||||
RetroSectionTitle(title: game_l10n.uiLanguage),
|
||||
const SizedBox(height: 8),
|
||||
_buildLanguageSelector(context),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 사운드 설정
|
||||
_RetroSectionTitle(title: game_l10n.uiSound),
|
||||
RetroSectionTitle(title: game_l10n.uiSound),
|
||||
const SizedBox(height: 8),
|
||||
_buildSoundSettings(context),
|
||||
|
||||
// 디버그 섹션 (디버그 모드에서만 표시)
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(height: 16),
|
||||
_RetroSectionTitle(title: L10n.of(context).debugTitle),
|
||||
RetroSectionTitle(title: L10n.of(context).debugTitle),
|
||||
const SizedBox(height: 8),
|
||||
_buildDebugSection(context),
|
||||
],
|
||||
@@ -223,7 +224,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final isSelected = currentLocale == lang.$1;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: _RetroSelectableItem(
|
||||
child: RetroSelectableItem(
|
||||
isSelected: isSelected,
|
||||
onTap: () {
|
||||
game_l10n.setGameLocale(lang.$1);
|
||||
@@ -266,7 +267,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
children: [
|
||||
_RetroVolumeSlider(
|
||||
RetroVolumeSlider(
|
||||
label: game_l10n.uiBgmVolume,
|
||||
icon: Icons.music_note,
|
||||
value: _bgmVolume,
|
||||
@@ -277,7 +278,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_RetroVolumeSlider(
|
||||
RetroVolumeSlider(
|
||||
label: game_l10n.uiSfxVolume,
|
||||
icon: Icons.volume_up,
|
||||
value: _sfxVolume,
|
||||
@@ -321,7 +322,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// IAP 시뮬레이션 토글
|
||||
_RetroDebugToggle(
|
||||
RetroDebugToggle(
|
||||
icon: Icons.shopping_cart,
|
||||
label: L10n.of(context).debugIapPurchased,
|
||||
description: L10n.of(context).debugIapPurchasedDesc,
|
||||
@@ -418,7 +419,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final isSelected = _debugOfflineHours == hours;
|
||||
final label = hours == 0 ? 'OFF' : '${hours}H';
|
||||
|
||||
return _RetroChip(
|
||||
return RetroChip(
|
||||
label: label,
|
||||
isSelected: isSelected,
|
||||
onTap: () async {
|
||||
@@ -435,7 +436,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Future<void> _handleCreateTestCharacter() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => _RetroConfirmDialog(
|
||||
builder: (context) => RetroConfirmDialog(
|
||||
title: L10n.of(context).debugCreateTestCharacterTitle,
|
||||
message: L10n.of(context).debugCreateTestCharacterMessage,
|
||||
confirmText: L10n.of(context).createButton,
|
||||
@@ -452,370 +453,3 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 레트로 스타일 서브 위젯들
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// 섹션 타이틀
|
||||
class _RetroSectionTitle extends StatelessWidget {
|
||||
const _RetroSectionTitle({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(width: 4, height: 14, color: RetroColors.goldOf(context)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: RetroColors.goldOf(context),
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 선택 가능한 아이템
|
||||
class _RetroSelectableItem extends StatelessWidget {
|
||||
const _RetroSelectableItem({
|
||||
required this.child,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? RetroColors.goldOf(context).withValues(alpha: 0.15)
|
||||
: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? RetroColors.goldOf(context)
|
||||
: RetroColors.borderOf(context),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 볼륨 슬라이더
|
||||
class _RetroVolumeSlider extends StatelessWidget {
|
||||
const _RetroVolumeSlider({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final double value;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final percentage = (value * 100).round();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
value == 0 ? Icons.volume_off : icon,
|
||||
size: 14,
|
||||
color: RetroColors.goldOf(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'$percentage%',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: RetroColors.goldOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 레트로 스타일 슬라이더
|
||||
_RetroSlider(value: value, onChanged: onChanged),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 레트로 스타일 슬라이더
|
||||
class _RetroSlider extends StatelessWidget {
|
||||
const _RetroSlider({required this.value, required this.onChanged});
|
||||
|
||||
final double value;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 8,
|
||||
activeTrackColor: RetroColors.goldOf(context),
|
||||
inactiveTrackColor: RetroColors.borderOf(context),
|
||||
thumbColor: RetroColors.goldLightOf(context),
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
|
||||
overlayColor: RetroColors.goldOf(context).withValues(alpha: 0.2),
|
||||
trackShape: const RectangularSliderTrackShape(),
|
||||
),
|
||||
child: Slider(value: value, onChanged: onChanged, divisions: 10),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 디버그 토글
|
||||
class _RetroDebugToggle extends StatelessWidget {
|
||||
const _RetroDebugToggle({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.description,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String description;
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: RetroColors.textPrimaryOf(context)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
color: RetroColors.textMutedOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 레트로 스타일 토글
|
||||
_RetroToggle(value: value, onChanged: onChanged),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 레트로 스타일 토글
|
||||
class _RetroToggle extends StatelessWidget {
|
||||
const _RetroToggle({required this.value, required this.onChanged});
|
||||
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(!value),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: value
|
||||
? RetroColors.goldOf(context).withValues(alpha: 0.3)
|
||||
: RetroColors.borderOf(context).withValues(alpha: 0.3),
|
||||
border: Border.all(
|
||||
color: value
|
||||
? RetroColors.goldOf(context)
|
||||
: RetroColors.borderOf(context),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: AnimatedAlign(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
margin: const EdgeInsets.all(1),
|
||||
color: value
|
||||
? RetroColors.goldOf(context)
|
||||
: RetroColors.textMutedOf(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 레트로 스타일 칩
|
||||
class _RetroChip extends StatelessWidget {
|
||||
const _RetroChip({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? RetroColors.goldOf(context).withValues(alpha: 0.2)
|
||||
: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? RetroColors.goldOf(context)
|
||||
: RetroColors.borderOf(context),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: isSelected
|
||||
? RetroColors.goldOf(context)
|
||||
: RetroColors.textMutedOf(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 레트로 스타일 확인 다이얼로그
|
||||
class _RetroConfirmDialog extends StatelessWidget {
|
||||
const _RetroConfirmDialog({
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.confirmText,
|
||||
required this.cancelText,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String message;
|
||||
final String confirmText;
|
||||
final String cancelText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.backgroundOf(context),
|
||||
border: Border.all(color: RetroColors.goldOf(context), width: 3),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 타이틀
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: RetroColors.goldOf(context).withValues(alpha: 0.2),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: RetroColors.goldOf(context),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// 메시지
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
height: 1.8,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// 버튼
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RetroTextButton(
|
||||
text: cancelText,
|
||||
isPrimary: false,
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: RetroTextButton(
|
||||
text: confirmText,
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/ascii_animation_type.dart';
|
||||
|
||||
/// ASCII 애니메이션 프레임 데이터
|
||||
class AsciiAnimationData {
|
||||
@@ -1,7 +1,7 @@
|
||||
// 환경별 배경 패턴 데이터
|
||||
// ASCII Patrol 스타일 - 패럴렉스 스크롤링 배경
|
||||
|
||||
import 'package:asciineverdie/src/core/animation/background_layer.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/background_layer.dart';
|
||||
|
||||
/// 환경별 배경 레이어 반환
|
||||
List<BackgroundLayer> getBackgroundLayers(EnvironmentType environment) {
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
|
||||
import 'package:asciineverdie/src/core/constants/ascii_colors.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_layer.dart';
|
||||
import 'package:asciineverdie/src/shared/theme/ascii_colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Paragraph 캐시 키
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_painter.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
|
||||
import 'package:asciineverdie/src/core/constants/ascii_colors.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_canvas_painter.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_layer.dart';
|
||||
import 'package:asciineverdie/src/shared/theme/ascii_colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// ASCII Canvas 위젯 (RepaintBoundary 포함)
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_cell.dart';
|
||||
|
||||
/// ASCII 레이어 데이터 구조 (Canvas 렌더러용)
|
||||
///
|
||||
544
lib/src/shared/animation/canvas/canvas_battle_composer.dart
Normal file
544
lib/src/shared/animation/canvas/canvas_battle_composer.dart
Normal file
@@ -0,0 +1,544 @@
|
||||
import 'package:asciineverdie/src/shared/animation/ascii_animation_data.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/background_data.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/background_layer.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_layer.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/combat_text_frames.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/monster_frames.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/rarity_color_mapper.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/character_frames.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/race_character_frames.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/weapon_category.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/weapon_effects.dart';
|
||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||
|
||||
// 하위 호환성(backward compatibility)을 위한 re-export
|
||||
export 'package:asciineverdie/src/shared/animation/canvas/monster_frames.dart'
|
||||
show getMonsterIdleFrames;
|
||||
|
||||
/// Canvas용 전투 프레임 합성기
|
||||
///
|
||||
/// 기존 BattleComposer의 로직을 레이어 기반으로 변환.
|
||||
/// 출력: `List<AsciiLayer>` (z-order 정렬됨)
|
||||
///
|
||||
/// PvP 모드: [opponentRaceId]가 설정되면 몬스터 대신 상대 캐릭터(좌우 반전) 표시
|
||||
class CanvasBattleComposer {
|
||||
const CanvasBattleComposer({
|
||||
required this.weaponCategory,
|
||||
required this.hasShield,
|
||||
required this.monsterCategory,
|
||||
required this.monsterSize,
|
||||
this.raceId,
|
||||
this.weaponRarity,
|
||||
this.opponentRaceId,
|
||||
this.opponentHasShield = false,
|
||||
});
|
||||
|
||||
final WeaponCategory weaponCategory;
|
||||
final bool hasShield;
|
||||
final MonsterCategory monsterCategory;
|
||||
final MonsterSize monsterSize;
|
||||
|
||||
/// 종족 ID (Phase 4: 종족별 캐릭터 애니메이션)
|
||||
final String? raceId;
|
||||
|
||||
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
|
||||
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 frameHeight = 8;
|
||||
static const int monsterWidth = 18;
|
||||
|
||||
/// 레이어 기반 프레임 생성
|
||||
List<AsciiLayer> composeLayers(
|
||||
BattlePhase phase,
|
||||
int subFrame,
|
||||
String? monsterBaseName,
|
||||
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,
|
||||
bool hideMonster = false,
|
||||
}) {
|
||||
final layers = <AsciiLayer>[
|
||||
_createBackgroundLayer(environment, globalTick),
|
||||
_createCharacterLayer(phase, subFrame, attacker),
|
||||
// PvP 모드: 몬스터 대신 상대 캐릭터(좌우 반전) 표시
|
||||
// hideMonster: 몬스터 사망 애니메이션 중에는 렌더링 안함
|
||||
if (!hideMonster)
|
||||
isPvP
|
||||
? _createOpponentCharacterLayer(phase, subFrame, attacker)
|
||||
: _createMonsterLayer(phase, subFrame, attacker),
|
||||
];
|
||||
|
||||
// 이펙트 레이어 (준비/공격/히트 페이즈에서, 공격자 있을 때)
|
||||
if ((phase == BattlePhase.prepare ||
|
||||
phase == BattlePhase.attack ||
|
||||
phase == BattlePhase.hit) &&
|
||||
attacker != AttackerType.none) {
|
||||
final effectLayer = _createEffectLayer(phase, subFrame, attacker);
|
||||
if (effectLayer != null) {
|
||||
layers.add(effectLayer);
|
||||
}
|
||||
}
|
||||
|
||||
// 텍스트 이펙트 레이어
|
||||
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));
|
||||
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));
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/// 배경 레이어 생성 (z=0)
|
||||
AsciiLayer _createBackgroundLayer(
|
||||
EnvironmentType environment,
|
||||
int globalTick,
|
||||
) {
|
||||
final cells = List.generate(
|
||||
frameHeight,
|
||||
(_) => List.filled(frameWidth, AsciiCell.empty),
|
||||
);
|
||||
|
||||
final bgLayers = getBackgroundLayers(environment);
|
||||
for (final layer in bgLayers) {
|
||||
final offset = (globalTick * layer.scrollSpeed).toInt();
|
||||
for (var i = 0; i < layer.lines.length; i++) {
|
||||
final y = layer.yStart + i;
|
||||
if (y >= frameHeight) break;
|
||||
final pattern = layer.lines[i];
|
||||
if (pattern.isEmpty) continue;
|
||||
for (var x = 0; x < frameWidth; x++) {
|
||||
final patternIdx = (x + offset) % pattern.length;
|
||||
final char = pattern[patternIdx];
|
||||
if (char != ' ') {
|
||||
cells[y][x] = AsciiCell.fromChar(char);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AsciiLayer(cells: cells, zIndex: 0, opacity: 0.5);
|
||||
}
|
||||
|
||||
/// 캐릭터 레이어 생성 (z=2)
|
||||
AsciiLayer _createCharacterLayer(
|
||||
BattlePhase phase,
|
||||
int subFrame,
|
||||
AttackerType attacker,
|
||||
) {
|
||||
CharacterFrame charFrame;
|
||||
if (raceId != null && raceId!.isNotEmpty) {
|
||||
final raceData = RaceCharacterFrames.get(raceId!);
|
||||
if (raceData != null) {
|
||||
final frames = raceData.getFrames(phase);
|
||||
charFrame = frames[subFrame % frames.length];
|
||||
} else {
|
||||
charFrame = getCharacterFrame(phase, subFrame);
|
||||
}
|
||||
} else {
|
||||
charFrame = getCharacterFrame(phase, subFrame);
|
||||
}
|
||||
|
||||
if (hasShield) charFrame = charFrame.withShield();
|
||||
|
||||
final isPlayerAttacking =
|
||||
attacker == AttackerType.player || attacker == AttackerType.both;
|
||||
final charX = _getCharacterX(phase, isPlayerAttacking);
|
||||
|
||||
final cells = _spriteToCells(charFrame.lines);
|
||||
final charY = frameHeight - cells.length - 1;
|
||||
|
||||
return AsciiLayer(
|
||||
cells: cells,
|
||||
zIndex: 2,
|
||||
offsetX: charX,
|
||||
offsetY: charY,
|
||||
);
|
||||
}
|
||||
|
||||
/// 몬스터 레이어 생성 (z=1)
|
||||
AsciiLayer _createMonsterLayer(
|
||||
BattlePhase phase,
|
||||
int subFrame,
|
||||
AttackerType attacker,
|
||||
) {
|
||||
final monsterFrames = getAnimatedMonsterFrames(
|
||||
monsterCategory,
|
||||
monsterSize,
|
||||
phase,
|
||||
);
|
||||
final monsterFrame = monsterFrames[subFrame % monsterFrames.length];
|
||||
final cells = _spriteToRightAlignedCells(monsterFrame, monsterWidth);
|
||||
|
||||
final isMonsterAttacking =
|
||||
attacker == AttackerType.monster || attacker == AttackerType.both;
|
||||
final monsterRightEdge = _getMonsterRightEdge(phase, isMonsterAttacking);
|
||||
final monsterX = monsterRightEdge - monsterWidth;
|
||||
final monsterY = frameHeight - cells.length - 1;
|
||||
|
||||
return AsciiLayer(
|
||||
cells: cells,
|
||||
zIndex: 1,
|
||||
offsetX: monsterX,
|
||||
offsetY: monsterY,
|
||||
);
|
||||
}
|
||||
|
||||
/// 상대 캐릭터 레이어 생성 (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;
|
||||
|
||||
const opponentWidth = 6;
|
||||
final opponentRightEdge = _getMonsterRightEdge(phase, isOpponentAttacking);
|
||||
final opponentX = opponentRightEdge - opponentWidth;
|
||||
|
||||
final cells = _spriteToCells(mirroredLines);
|
||||
final opponentY = frameHeight - cells.length - 1;
|
||||
|
||||
return AsciiLayer(
|
||||
cells: cells,
|
||||
zIndex: 1,
|
||||
offsetX: opponentX,
|
||||
offsetY: opponentY,
|
||||
);
|
||||
}
|
||||
|
||||
/// 이펙트 레이어 생성 (z=3)
|
||||
AsciiLayer? _createEffectLayer(
|
||||
BattlePhase phase,
|
||||
int subFrame,
|
||||
AttackerType attacker,
|
||||
) {
|
||||
final isPlayerAttacking =
|
||||
attacker == AttackerType.player || attacker == AttackerType.both;
|
||||
final isMonsterAttacking =
|
||||
attacker == AttackerType.monster || attacker == AttackerType.both;
|
||||
|
||||
const charWidth = 6;
|
||||
final charX = _getCharacterX(phase, isPlayerAttacking);
|
||||
final monsterRightEdge = _getMonsterRightEdge(phase, isMonsterAttacking);
|
||||
final monsterX = monsterRightEdge - monsterWidth;
|
||||
|
||||
final List<String> effectLines;
|
||||
final int effectX;
|
||||
|
||||
if (attacker == AttackerType.player) {
|
||||
final effect = getWeaponEffect(weaponCategory);
|
||||
effectLines = _getEffectLines(effect, phase, subFrame);
|
||||
effectX = monsterX - 2;
|
||||
} else if (attacker == AttackerType.monster) {
|
||||
effectLines = _getMonsterAttackEffect(phase, subFrame);
|
||||
effectX = charX + charWidth;
|
||||
} else {
|
||||
final effect = getWeaponEffect(weaponCategory);
|
||||
effectLines = _getEffectLines(effect, phase, subFrame);
|
||||
effectX = (charX + charWidth + monsterX) ~/ 2;
|
||||
}
|
||||
|
||||
if (effectLines.isEmpty) return null;
|
||||
|
||||
final List<List<AsciiCell>> cells;
|
||||
if (attacker == AttackerType.player && weaponRarity != null) {
|
||||
cells = _spriteToCellsWithColor(
|
||||
effectLines,
|
||||
weaponRarity!.effectCellColor,
|
||||
);
|
||||
} else {
|
||||
cells = _spriteToCells(effectLines);
|
||||
}
|
||||
|
||||
final effectHeight = effectLines.length;
|
||||
final effectY = frameHeight - effectHeight - 1;
|
||||
|
||||
return AsciiLayer(
|
||||
cells: cells,
|
||||
zIndex: 3,
|
||||
offsetX: effectX,
|
||||
offsetY: effectY,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 텍스트 이펙트 레이어
|
||||
// ============================================================================
|
||||
|
||||
AsciiLayer _createCriticalTextLayer(int subFrame) {
|
||||
final textLines = subFrame % 2 == 0
|
||||
? critTextFrames[0]
|
||||
: critTextFrames[1];
|
||||
final cells = _textLinesToCells(textLines, AsciiCellColor.positive);
|
||||
final textWidth = textLines.isNotEmpty ? textLines[0].length : 0;
|
||||
final offsetX = (frameWidth - textWidth) ~/ 2;
|
||||
return AsciiLayer(cells: cells, zIndex: 4, offsetX: offsetX, offsetY: 0);
|
||||
}
|
||||
|
||||
AsciiLayer _createEvadeTextLayer(int subFrame) {
|
||||
return _createTextLayer(
|
||||
frames: evadeTextFrames,
|
||||
subFrame: subFrame,
|
||||
color: AsciiCellColor.positive,
|
||||
offsetX: 15,
|
||||
);
|
||||
}
|
||||
|
||||
AsciiLayer _createMissTextLayer(int subFrame) {
|
||||
return _createTextLayer(
|
||||
frames: missTextFrames,
|
||||
subFrame: subFrame,
|
||||
color: AsciiCellColor.negative,
|
||||
offsetX: 35,
|
||||
);
|
||||
}
|
||||
|
||||
AsciiLayer _createDebuffTextLayer(int subFrame) {
|
||||
return _createTextLayer(
|
||||
frames: debuffTextFrames,
|
||||
subFrame: subFrame,
|
||||
color: AsciiCellColor.negative,
|
||||
offsetX: 35,
|
||||
);
|
||||
}
|
||||
|
||||
AsciiLayer _createDotTextLayer(int subFrame) {
|
||||
return _createTextLayer(
|
||||
frames: dotTextFrames,
|
||||
subFrame: subFrame,
|
||||
color: AsciiCellColor.negative,
|
||||
offsetX: 35,
|
||||
);
|
||||
}
|
||||
|
||||
AsciiLayer _createBlockTextLayer(int subFrame) {
|
||||
return _createTextLayer(
|
||||
frames: blockTextFrames,
|
||||
subFrame: subFrame,
|
||||
color: AsciiCellColor.positive,
|
||||
offsetX: 15,
|
||||
);
|
||||
}
|
||||
|
||||
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 = _textLinesToCells(textLines, color);
|
||||
return AsciiLayer(cells: cells, zIndex: 4, offsetX: offsetX, offsetY: 0);
|
||||
}
|
||||
|
||||
/// 텍스트 라인을 AsciiCell 배열로 변환
|
||||
List<List<AsciiCell>> _textLinesToCells(
|
||||
List<String> lines,
|
||||
AsciiCellColor color,
|
||||
) {
|
||||
return lines.map((String line) {
|
||||
return line.split('').map((String char) {
|
||||
if (char == ' ') return AsciiCell.empty;
|
||||
return AsciiCell(char: char, color: color);
|
||||
}).toList();
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 위치 계산 헬퍼
|
||||
// ============================================================================
|
||||
|
||||
/// 캐릭터 X 위치 계산
|
||||
int _getCharacterX(BattlePhase phase, bool isPlayerAttacking) {
|
||||
return switch (phase) {
|
||||
BattlePhase.idle => 12,
|
||||
BattlePhase.prepare => isPlayerAttacking ? 15 : 12,
|
||||
BattlePhase.attack => isPlayerAttacking ? 18 : 12,
|
||||
BattlePhase.hit => isPlayerAttacking ? 18 : 12,
|
||||
BattlePhase.recover => isPlayerAttacking ? 15 : 12,
|
||||
};
|
||||
}
|
||||
|
||||
/// 몬스터 오른쪽 가장자리 위치 계산
|
||||
int _getMonsterRightEdge(BattlePhase phase, bool isMonsterAttacking) {
|
||||
return switch (phase) {
|
||||
BattlePhase.idle => 48,
|
||||
BattlePhase.prepare => isMonsterAttacking ? 45 : 48,
|
||||
BattlePhase.attack => isMonsterAttacking ? 42 : 48,
|
||||
BattlePhase.hit => isMonsterAttacking ? 42 : 48,
|
||||
BattlePhase.recover => isMonsterAttacking ? 45 : 48,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 이펙트 헬퍼
|
||||
// ============================================================================
|
||||
|
||||
/// 몬스터 공격 이펙트 (방향)
|
||||
List<String> _getMonsterAttackEffect(BattlePhase phase, int subFrame) {
|
||||
return switch (phase) {
|
||||
BattlePhase.prepare =>
|
||||
monsterPrepareFrames[subFrame % monsterPrepareFrames.length],
|
||||
BattlePhase.attack =>
|
||||
monsterAttackFrames[subFrame % monsterAttackFrames.length],
|
||||
BattlePhase.hit => monsterHitFrames[subFrame % monsterHitFrames.length],
|
||||
_ => <String>[],
|
||||
};
|
||||
}
|
||||
|
||||
/// 멀티라인 이펙트 프레임 반환
|
||||
List<String> _getEffectLines(
|
||||
WeaponEffect effect,
|
||||
BattlePhase phase,
|
||||
int subFrame,
|
||||
) {
|
||||
final frames = switch (phase) {
|
||||
BattlePhase.idle => <List<String>>[],
|
||||
BattlePhase.prepare => effect.prepareFrames,
|
||||
BattlePhase.attack => effect.attackFrames,
|
||||
BattlePhase.hit => effect.hitFrames,
|
||||
BattlePhase.recover => <List<String>>[],
|
||||
};
|
||||
if (frames.isEmpty) return [];
|
||||
return frames[subFrame % frames.length];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 스프라이트 변환 유틸리티
|
||||
// ============================================================================
|
||||
|
||||
/// 문자열 좌우 반전 (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'\' => '/',
|
||||
'(' => ')',
|
||||
')' => '(',
|
||||
'[' => ']',
|
||||
']' => '[',
|
||||
'{' => '}',
|
||||
'}' => '{',
|
||||
'<' => '>',
|
||||
'>' => '<',
|
||||
'\u2518' => '\u2514',
|
||||
'\u2514' => '\u2518',
|
||||
'\u2510' => '\u250c',
|
||||
'\u250c' => '\u2510',
|
||||
_ => char,
|
||||
};
|
||||
}
|
||||
|
||||
/// 문자열 스프라이트를 AsciiCell 2D 배열로 변환
|
||||
List<List<AsciiCell>> _spriteToCells(List<String> lines) {
|
||||
return lines.map((line) {
|
||||
return line.split('').map(AsciiCell.fromChar).toList();
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 문자열 스프라이트를 지정된 색상으로 변환
|
||||
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,
|
||||
int width,
|
||||
) {
|
||||
int maxWidth = 0;
|
||||
for (final line in lines) {
|
||||
final trimmed = line.trimRight().length;
|
||||
if (trimmed > maxWidth) maxWidth = trimmed;
|
||||
}
|
||||
|
||||
final leftPadding = width - maxWidth;
|
||||
|
||||
return lines.map((line) {
|
||||
final trimmed = line.trimRight();
|
||||
final cells = <AsciiCell>[];
|
||||
for (var i = 0; i < leftPadding; i++) {
|
||||
cells.add(AsciiCell.empty);
|
||||
}
|
||||
for (var i = 0; i < trimmed.length; i++) {
|
||||
cells.add(AsciiCell.fromChar(trimmed[i]));
|
||||
}
|
||||
while (cells.length < width) {
|
||||
cells.add(AsciiCell.empty);
|
||||
}
|
||||
return cells;
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/ascii_animation_type.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_layer.dart';
|
||||
|
||||
/// Canvas용 특수 이벤트 애니메이션 합성기
|
||||
///
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
|
||||
import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_layer.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/race_character_frames.dart';
|
||||
|
||||
/// Canvas용 마을/상점 애니메이션 합성기
|
||||
///
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:asciineverdie/src/core/animation/background_data.dart';
|
||||
import 'package:asciineverdie/src/core/animation/background_layer.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
|
||||
import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/background_data.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/background_layer.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_layer.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/race_character_frames.dart';
|
||||
|
||||
/// Canvas용 걷기 애니메이션 합성기
|
||||
///
|
||||
77
lib/src/shared/animation/canvas/combat_text_frames.dart
Normal file
77
lib/src/shared/animation/canvas/combat_text_frames.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
/// 전투 텍스트 이펙트 프레임 데이터
|
||||
///
|
||||
/// CanvasBattleComposer에서 분리된 전투 텍스트 프레임 상수.
|
||||
/// 크리티컬, 회피, 미스, 디버프, DOT, 블록, 패리 텍스트 프레임.
|
||||
|
||||
// ============================================================================
|
||||
// 몬스터 공격 이펙트 (← 방향, Phase 8) - 5줄
|
||||
// ============================================================================
|
||||
|
||||
/// 몬스터 공격 준비 프레임 (5줄)
|
||||
const monsterPrepareFrames = <List<String>>[
|
||||
[r' ', r' ', r' < ', r' ', r' '],
|
||||
[r' ', r' _ ', r' << ', r' - ', r' '],
|
||||
];
|
||||
|
||||
/// 몬스터 공격 프레임 (5줄)
|
||||
const monsterAttackFrames = <List<String>>[
|
||||
[r' ', r' __ ', r' <-- ', r' -- ', r' '],
|
||||
[r' ', r' ___ ', r' <--- ', r' --- ', r' '],
|
||||
[r' ', r' ____ ', r' <----- ', r' ---- ', r' '],
|
||||
];
|
||||
|
||||
/// 몬스터 히트 프레임 (5줄)
|
||||
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'********'],
|
||||
];
|
||||
765
lib/src/shared/animation/canvas/monster_frames.dart
Normal file
765
lib/src/shared/animation/canvas/monster_frames.dart
Normal file
@@ -0,0 +1,765 @@
|
||||
import 'package:asciineverdie/src/shared/animation/ascii_animation_data.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/character_frames.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
|
||||
|
||||
/// 몬스터 애니메이션 프레임 데이터
|
||||
///
|
||||
/// CanvasBattleComposer에서 분리된 몬스터 스프라이트 프레임.
|
||||
/// 카테고리/크기/페이즈별 프레임을 제공한다.
|
||||
|
||||
/// 몬스터 애니메이션 프레임 반환 (페이즈별 다른 동작)
|
||||
List<List<String>> getAnimatedMonsterFrames(
|
||||
MonsterCategory category,
|
||||
MonsterSize size,
|
||||
BattlePhase phase,
|
||||
) {
|
||||
// 피격 상태
|
||||
if (phase == BattlePhase.hit) {
|
||||
return getMonsterHitFrames(category, size);
|
||||
}
|
||||
// 경계 상태 (prepare, attack)
|
||||
if (phase == BattlePhase.prepare || phase == BattlePhase.attack) {
|
||||
return _getMonsterAlertFrames(category, size);
|
||||
}
|
||||
// 일반 상태 (idle, recover)
|
||||
return getMonsterIdleFrames(category, size);
|
||||
}
|
||||
|
||||
/// 몬스터 Idle 프레임 가져오기 (외부에서 접근 가능)
|
||||
///
|
||||
/// 몬스터 사망 애니메이션에서 분해할 프레임을 가져올 때 사용
|
||||
List<List<String>> getMonsterIdleFrames(
|
||||
MonsterCategory category,
|
||||
MonsterSize size,
|
||||
) {
|
||||
return switch (size) {
|
||||
MonsterSize.tiny => _tinyIdleFrames(category),
|
||||
MonsterSize.small => _smallIdleFrames(category),
|
||||
MonsterSize.medium => _mediumIdleFrames(category),
|
||||
// large, huge, giant, titanic 모두 8줄 (large 프레임 사용)
|
||||
MonsterSize.large ||
|
||||
MonsterSize.huge ||
|
||||
MonsterSize.giant ||
|
||||
MonsterSize.titanic => _largeIdleFrames(category),
|
||||
};
|
||||
}
|
||||
|
||||
List<List<String>> getMonsterHitFrames(
|
||||
MonsterCategory category,
|
||||
MonsterSize size,
|
||||
) {
|
||||
return switch (size) {
|
||||
MonsterSize.tiny => _tinyHitFrames(category),
|
||||
MonsterSize.small => _smallHitFrames(category),
|
||||
MonsterSize.medium => _mediumHitFrames(category),
|
||||
MonsterSize.large ||
|
||||
MonsterSize.huge ||
|
||||
MonsterSize.giant ||
|
||||
MonsterSize.titanic => _largeHitFrames(category),
|
||||
};
|
||||
}
|
||||
|
||||
List<List<String>> _getMonsterAlertFrames(
|
||||
MonsterCategory category,
|
||||
MonsterSize size,
|
||||
) {
|
||||
return switch (size) {
|
||||
MonsterSize.tiny => _tinyAlertFrames(category),
|
||||
MonsterSize.small => _smallAlertFrames(category),
|
||||
MonsterSize.medium => _mediumAlertFrames(category),
|
||||
MonsterSize.large ||
|
||||
MonsterSize.huge ||
|
||||
MonsterSize.giant ||
|
||||
MonsterSize.titanic => _largeAlertFrames(category),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tiny 몬스터 (2줄)
|
||||
// ============================================================================
|
||||
|
||||
List<List<String>> _tinyIdleFrames(MonsterCategory category) {
|
||||
return switch (category) {
|
||||
MonsterCategory.bug => [
|
||||
[r'(o.o)', r' |_|'],
|
||||
[r'(o o)', r' |_|'],
|
||||
],
|
||||
MonsterCategory.malware => [
|
||||
[r'<o_o>', r' \_/'],
|
||||
[r'<o-o>', r' /_\'],
|
||||
],
|
||||
MonsterCategory.network => [
|
||||
[r' @', r'/|\'],
|
||||
[r' @', r'\|/'],
|
||||
],
|
||||
MonsterCategory.system => [
|
||||
[r'[x_x]', r' /_\'],
|
||||
[r'[X_X]', r' \_/'],
|
||||
],
|
||||
MonsterCategory.crypto => [
|
||||
[r'$(.)~', r' /_\'],
|
||||
[r'$(.)`', r' \_/'],
|
||||
],
|
||||
MonsterCategory.ai => [
|
||||
[r'{o}', r'/_\'],
|
||||
[r'{O}', r'\_/'],
|
||||
],
|
||||
MonsterCategory.boss => [
|
||||
[r'(|o|)', r' V V'],
|
||||
[r'(|O|)', r' v v'],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
List<List<String>> _tinyHitFrames(MonsterCategory category) {
|
||||
return [
|
||||
[r'(*!*)', r' X_X'],
|
||||
[r'(!*!)', r' x_x'],
|
||||
];
|
||||
}
|
||||
|
||||
List<List<String>> _tinyAlertFrames(MonsterCategory category) {
|
||||
return switch (category) {
|
||||
MonsterCategory.bug => [
|
||||
[r'(O!O)', r' |!|'],
|
||||
[r'(!O!)', r' |!|'],
|
||||
],
|
||||
MonsterCategory.malware => [
|
||||
[r'<!_!>', r' \!/'],
|
||||
[r'<!-!>', r' /!\'],
|
||||
],
|
||||
MonsterCategory.network => [
|
||||
[r' @!', r'/|\'],
|
||||
[r' !@', r'\|/'],
|
||||
],
|
||||
MonsterCategory.system => [
|
||||
[r'[!_!]', r' /!\'],
|
||||
[r'[!_!]', r' \!/'],
|
||||
],
|
||||
MonsterCategory.crypto => [
|
||||
[r'$(!)~', r' /!\'],
|
||||
[r'$(!)`', r' \!/'],
|
||||
],
|
||||
MonsterCategory.ai => [
|
||||
[r'{!}', r'/!\'],
|
||||
[r'{!}', r'\!/'],
|
||||
],
|
||||
MonsterCategory.boss => [
|
||||
[r'(|!|)', r' V!V'],
|
||||
[r'(|!|)', r' v!v'],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Small 몬스터 (4줄)
|
||||
// ============================================================================
|
||||
|
||||
List<List<String>> _smallIdleFrames(MonsterCategory category) {
|
||||
return switch (category) {
|
||||
MonsterCategory.bug => [
|
||||
[r' /\_/\', r'( O.O )', r' > ^ <', r' /| |\'],
|
||||
[r' /\_/\', r'( O O )', r' > v <', r' \| |/'],
|
||||
],
|
||||
MonsterCategory.malware => [
|
||||
[r' /\/\', r' (O O)', r' / \', r' \/ \/'],
|
||||
[r' \/\/\', r' (O O)', r' \ /', r' /\ /\'],
|
||||
],
|
||||
MonsterCategory.network => [
|
||||
[r' O', r' /|\', r' / \', r' _| |_'],
|
||||
[r' O', r' \|/', r' | |', r' _/ \_'],
|
||||
],
|
||||
MonsterCategory.system => [
|
||||
[r' _+_', r' (x_x)', r' /|\', r' _/ \_'],
|
||||
[r' _+_', r' (X_X)', r' \|/', r' _| |_'],
|
||||
],
|
||||
MonsterCategory.crypto => [
|
||||
[r' __', r' <(oo)~', r' / \', r' <_ _>'],
|
||||
[r' __', r' (oo)>', r' \ /', r' <_ _>'],
|
||||
],
|
||||
MonsterCategory.ai => [
|
||||
[r' ___', r' ( )', r' ( )', r' \_/'],
|
||||
[r' _', r' / \', r' { }', r' \_/'],
|
||||
],
|
||||
MonsterCategory.boss => [
|
||||
[r' ^w^', r' (|o|)', r' /|\', r' V V'],
|
||||
[r' ^W^', r' (|O|)', r' \|/', r' v v'],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
List<List<String>> _smallHitFrames(MonsterCategory category) {
|
||||
return [
|
||||
[r' *!*', r' (>_<)', r' \X/', r' _/_\_'],
|
||||
[r' !*!', r' (@_@)', r' /X\', r' _\_/_'],
|
||||
];
|
||||
}
|
||||
|
||||
List<List<String>> _smallAlertFrames(MonsterCategory category) {
|
||||
return switch (category) {
|
||||
MonsterCategory.bug => [
|
||||
[r' /\_/\', r'( O!O )', r' > ! <', r' /| |\'],
|
||||
[r' /\_/\', r'( !O! )', r' > ! <', r' \| |/'],
|
||||
],
|
||||
MonsterCategory.malware => [
|
||||
[r' /\/\', r' (! !)', r' / \', r' \/ \/'],
|
||||
[r' \/\/\', r' (! !)', r' \ /', r' /\ /\'],
|
||||
],
|
||||
MonsterCategory.network => [
|
||||
[r' O!', r' /|\', r' / \', r' _| |_'],
|
||||
[r' !O', r' \|/', r' | |', r' _/ \_'],
|
||||
],
|
||||
MonsterCategory.system => [
|
||||
[r' _!_', r' (!_!)', r' /|\', r' _/ \_'],
|
||||
[r' _!_', r' (!_!)', r' \|/', r' _| |_'],
|
||||
],
|
||||
MonsterCategory.crypto => [
|
||||
[r' __', r' <(!!)~', r' / \', r' <_ _>'],
|
||||
[r' __', r' (!!)>', r' \ /', r' <_ _>'],
|
||||
],
|
||||
MonsterCategory.ai => [
|
||||
[r' ___', r' ( ! )', r' ( ! )', r' \_/'],
|
||||
[r' _', r' /!\', r' { ! }', r' \_/'],
|
||||
],
|
||||
MonsterCategory.boss => [
|
||||
[r' ^!^', r' (|!|)', r' /|\', r' V V'],
|
||||
[r' ^!^', r' (|!|)', r' \|/', r' v v'],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Medium 몬스터 (6줄)
|
||||
// ============================================================================
|
||||
|
||||
List<List<String>> _mediumIdleFrames(MonsterCategory category) {
|
||||
return switch (category) {
|
||||
MonsterCategory.bug => [
|
||||
[
|
||||
r' /\_/\',
|
||||
r' ( O.O )',
|
||||
r' > ^ <',
|
||||
r' /| |\',
|
||||
r' | | | |',
|
||||
r'_|_| |_|_',
|
||||
],
|
||||
[
|
||||
r' /\_/\',
|
||||
r' ( O O )',
|
||||
r' > v <',
|
||||
r' \| |/',
|
||||
r' | | | |',
|
||||
r'_|_| |_|_',
|
||||
],
|
||||
],
|
||||
MonsterCategory.malware => [
|
||||
[
|
||||
r' /\/\',
|
||||
r' /O O\',
|
||||
r' \ /',
|
||||
r' / \',
|
||||
r' \/ \/',
|
||||
r' _/ \_',
|
||||
],
|
||||
[
|
||||
r' \/\/\',
|
||||
r' \O O/',
|
||||
r' / \',
|
||||
r' \ /',
|
||||
r' /\ /\',
|
||||
r' _\ /_',
|
||||
],
|
||||
],
|
||||
MonsterCategory.network => [
|
||||
[r' O', r' /|\', r' / \', r' | |', r' | |', r' _| |_'],
|
||||
[r' O', r' \|/', r' | |', r' | |', r' | |', r' _/ \_'],
|
||||
],
|
||||
MonsterCategory.system => [
|
||||
[r' _+_', r' (X_X)', r' /|\', r' / | \', r' | | |', r'_/ | \_'],
|
||||
[r' _x_', r' (x_x)', r' \|/', r' \ | /', r' | | |', r'_\ | /_'],
|
||||
],
|
||||
MonsterCategory.crypto => [
|
||||
[
|
||||
r' __',
|
||||
r' <(OO)~',
|
||||
r' / \',
|
||||
r' / \',
|
||||
r' | |',
|
||||
r'<__ __>',
|
||||
],
|
||||
[
|
||||
r' __',
|
||||
r' (OO)>',
|
||||
r' \ /',
|
||||
r' \ /',
|
||||
r' | |',
|
||||
r'<__ __>',
|
||||
],
|
||||
],
|
||||
MonsterCategory.ai => [
|
||||
[
|
||||
r' ____',
|
||||
r' / \',
|
||||
r' ( )',
|
||||
r' ( )',
|
||||
r' \ /',
|
||||
r' \__/',
|
||||
],
|
||||
[
|
||||
r' __',
|
||||
r' / \',
|
||||
r' / \',
|
||||
r' { }',
|
||||
r' \ /',
|
||||
r' \__/',
|
||||
],
|
||||
],
|
||||
MonsterCategory.boss => [
|
||||
[r' ^W^', r' (|O|)', r' /|\', r' / | \', r' V V', r' _/ \_'],
|
||||
[r' ^w^', r' (|o|)', r' \|/', r' \ | /', r' v v', r' _\ /_'],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
List<List<String>> _mediumHitFrames(MonsterCategory category) {
|
||||
return [
|
||||
[r' *!*', r' (>.<)', r' \X/', r' / \', r' | |', r'_/_ \_\'],
|
||||
[r' !*!', r' (@_@)', r' /X\', r' \ /', r' | |', r'_\_ /_/'],
|
||||
];
|
||||
}
|
||||
|
||||
List<List<String>> _mediumAlertFrames(MonsterCategory category) {
|
||||
return switch (category) {
|
||||
MonsterCategory.bug => [
|
||||
[
|
||||
r' /\_/\',
|
||||
r' ( O!O )',
|
||||
r' > ! <',
|
||||
r' /| |\',
|
||||
r' | | | |',
|
||||
r'_|_| |_|_',
|
||||
],
|
||||
[
|
||||
r' /\_/\',
|
||||
r' ( !O! )',
|
||||
r' > ! <',
|
||||
r' \| |/',
|
||||
r' | | | |',
|
||||
r'_|_| |_|_',
|
||||
],
|
||||
],
|
||||
MonsterCategory.malware => [
|
||||
[
|
||||
r' /\/\',
|
||||
r' /! !\',
|
||||
r' \ /',
|
||||
r' / \',
|
||||
r' \/ \/',
|
||||
r' _/ \_',
|
||||
],
|
||||
[
|
||||
r' \/\/\',
|
||||
r' \! !/',
|
||||
r' / \',
|
||||
r' \ /',
|
||||
r' /\ /\',
|
||||
r' _\ /_',
|
||||
],
|
||||
],
|
||||
MonsterCategory.network => [
|
||||
[r' O!', r' /|\', r' / \', r' | |', r' | |', r' _| |_'],
|
||||
[r' !O', r' \|/', r' | |', r' | |', r' | |', r' _/ \_'],
|
||||
],
|
||||
MonsterCategory.system => [
|
||||
[r' _!_', r' (!_!)', r' /|\', r' / | \', r' | | |', r'_/ | \_'],
|
||||
[r' _!_', r' (!_!)', r' \|/', r' \ | /', r' | | |', r'_\ | /_'],
|
||||
],
|
||||
MonsterCategory.crypto => [
|
||||
[
|
||||
r' __',
|
||||
r' <(!!)~',
|
||||
r' / \',
|
||||
r' / \',
|
||||
r' | |',
|
||||
r'<__ __>',
|
||||
],
|
||||
[
|
||||
r' __',
|
||||
r' (!!)>',
|
||||
r' \ /',
|
||||
r' \ /',
|
||||
r' | |',
|
||||
r'<__ __>',
|
||||
],
|
||||
],
|
||||
MonsterCategory.ai => [
|
||||
[
|
||||
r' ____',
|
||||
r' / ! \',
|
||||
r' ( ! )',
|
||||
r' ( ! )',
|
||||
r' \ /',
|
||||
r' \__/',
|
||||
],
|
||||
[
|
||||
r' __',
|
||||
r' / !\',
|
||||
r' / ! \',
|
||||
r' { ! }',
|
||||
r' \ /',
|
||||
r' \__/',
|
||||
],
|
||||
],
|
||||
MonsterCategory.boss => [
|
||||
[r' ^!^', r' (|!|)', r' /|\', r' / | \', r' V V', r' _/ \_'],
|
||||
[r' ^!^', r' (|!|)', r' \|/', r' \ | /', r' v v', r' _\ /_'],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Large 몬스터 (8줄)
|
||||
// ============================================================================
|
||||
|
||||
List<List<String>> _largeIdleFrames(MonsterCategory category) {
|
||||
return switch (category) {
|
||||
MonsterCategory.bug => [
|
||||
[
|
||||
r' /\__/\',
|
||||
r' ( O O )',
|
||||
r' > ^^ <',
|
||||
r' /| |\',
|
||||
r' | | | |',
|
||||
r' | | | |',
|
||||
r'_| | | |_',
|
||||
r'|__|____|__|',
|
||||
],
|
||||
[
|
||||
r' /\__/\',
|
||||
r' ( O O )',
|
||||
r' > vv <',
|
||||
r' \| |/',
|
||||
r' | | | |',
|
||||
r' | | | |',
|
||||
r'_| | | |_',
|
||||
r'|__|____|__|',
|
||||
],
|
||||
],
|
||||
MonsterCategory.malware => [
|
||||
[
|
||||
r' /\/\',
|
||||
r' /O O\',
|
||||
r' \ /',
|
||||
r' / \',
|
||||
r' / \',
|
||||
r' \/ \/',
|
||||
r' _/ \_',
|
||||
r'/__ __\\',
|
||||
],
|
||||
[
|
||||
r' \/\/\',
|
||||
r' \O O/',
|
||||
r' / \',
|
||||
r' \ /',
|
||||
r' \ /',
|
||||
r' /\ /\',
|
||||
r' _\ /_',
|
||||
r'\__ __/',
|
||||
],
|
||||
],
|
||||
MonsterCategory.network => [
|
||||
[
|
||||
r' O',
|
||||
r' /|\',
|
||||
r' / \',
|
||||
r' | |',
|
||||
r' | |',
|
||||
r' | |',
|
||||
r' _| |_',
|
||||
r'|__ __|',
|
||||
],
|
||||
[
|
||||
r' O',
|
||||
r' \|/',
|
||||
r' | |',
|
||||
r' | |',
|
||||
r' | |',
|
||||
r' | |',
|
||||
r' _/ \_',
|
||||
r'/__ __\\',
|
||||
],
|
||||
],
|
||||
MonsterCategory.system => [
|
||||
[
|
||||
r' _+_',
|
||||
r' (X_X)',
|
||||
r' /|\',
|
||||
r' / | \',
|
||||
r' | | |',
|
||||
r' | | |',
|
||||
r' _/ | \_',
|
||||
r'|____|____|',
|
||||
],
|
||||
[
|
||||
r' _x_',
|
||||
r' (x_x)',
|
||||
r' \|/',
|
||||
r' \ | /',
|
||||
r' | | |',
|
||||
r' | | |',
|
||||
r' _\ | /_',
|
||||
r'|____|____|',
|
||||
],
|
||||
],
|
||||
MonsterCategory.crypto => [
|
||||
[
|
||||
r' ___',
|
||||
r' <(O O)~',
|
||||
r' / \',
|
||||
r' / \',
|
||||
r' | |',
|
||||
r' | |',
|
||||
r' <__ __>',
|
||||
r'|___ ___|',
|
||||
],
|
||||
[
|
||||
r' ___',
|
||||
r' (O O)>',
|
||||
r' \ /',
|
||||
r' \ /',
|
||||
r' | |',
|
||||
r' | |',
|
||||
r' <__ __>',
|
||||
r'|___ ___|',
|
||||
],
|
||||
],
|
||||
MonsterCategory.ai => [
|
||||
[
|
||||
r' _____',
|
||||
r' / \',
|
||||
r' ( )',
|
||||
r' ( )',
|
||||
r' ( )',
|
||||
r' \ /',
|
||||
r' \ /',
|
||||
r' \_/',
|
||||
],
|
||||
[
|
||||
r' ___',
|
||||
r' / \',
|
||||
r' / \',
|
||||
r' { }',
|
||||
r' { }',
|
||||
r' \ /',
|
||||
r' \ /',
|
||||
r' \_/',
|
||||
],
|
||||
],
|
||||
MonsterCategory.boss => [
|
||||
[
|
||||
r' ^W^',
|
||||
r' /|O|\',
|
||||
r' /|\',
|
||||
r' / | \',
|
||||
r' | | |',
|
||||
r' V | V',
|
||||
r' _/ | \_',
|
||||
r'|_____|_____|',
|
||||
],
|
||||
[
|
||||
r' ^w^',
|
||||
r' \|o|/',
|
||||
r' \|/',
|
||||
r' \ | /',
|
||||
r' | | |',
|
||||
r' v | v',
|
||||
r' _\ | /_',
|
||||
r'|_____|_____|',
|
||||
],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
List<List<String>> _largeHitFrames(MonsterCategory category) {
|
||||
return [
|
||||
[
|
||||
r' *!*',
|
||||
r' (>.<)',
|
||||
r' \X/',
|
||||
r' / | \',
|
||||
r' | | |',
|
||||
r' X | X',
|
||||
r' _/ | \_',
|
||||
r'|_____|_____|',
|
||||
],
|
||||
[
|
||||
r' !*!',
|
||||
r' (@_@)',
|
||||
r' /X\',
|
||||
r' \ | /',
|
||||
r' | | |',
|
||||
r' x | x',
|
||||
r' _\ | /_',
|
||||
r'|_____|_____|',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
List<List<String>> _largeAlertFrames(MonsterCategory category) {
|
||||
return switch (category) {
|
||||
MonsterCategory.bug => [
|
||||
[
|
||||
r' /\__/\',
|
||||
r' ( O!O )',
|
||||
r' > !! <',
|
||||
r' /| |\',
|
||||
r' | | | |',
|
||||
r' | | | |',
|
||||
r'_| | | |_',
|
||||
r'|__|____|__|',
|
||||
],
|
||||
[
|
||||
r' /\__/\',
|
||||
r' ( !O! )',
|
||||
r' > !! <',
|
||||
r' \| |/',
|
||||
r' | | | |',
|
||||
r' | | | |',
|
||||
r'_| | | |_',
|
||||
r'|__|____|__|',
|
||||
],
|
||||
],
|
||||
MonsterCategory.malware => [
|
||||
[
|
||||
r' /\/\',
|
||||
r' /! !\',
|
||||
r' \ /',
|
||||
r' / \',
|
||||
r' / \',
|
||||
r' \/ \/',
|
||||
r' _/ \_',
|
||||
r'/__ __\\',
|
||||
],
|
||||
[
|
||||
r' \/\/\',
|
||||
r' \! !/',
|
||||
r' / \',
|
||||
r' \ /',
|
||||
r' \ /',
|
||||
r' /\ /\',
|
||||
r' _\ /_',
|
||||
r'\__ __/',
|
||||
],
|
||||
],
|
||||
MonsterCategory.network => [
|
||||
[
|
||||
r' O!',
|
||||
r' /|\',
|
||||
r' / \',
|
||||
r' | |',
|
||||
r' | |',
|
||||
r' | |',
|
||||
r' _| |_',
|
||||
r'|__ __|',
|
||||
],
|
||||
[
|
||||
r' !O',
|
||||
r' \|/',
|
||||
r' | |',
|
||||
r' | |',
|
||||
r' | |',
|
||||
r' | |',
|
||||
r' _/ \_',
|
||||
r'/__ __\\',
|
||||
],
|
||||
],
|
||||
MonsterCategory.system => [
|
||||
[
|
||||
r' _!_',
|
||||
r' (!_!)',
|
||||
r' /|\',
|
||||
r' / | \',
|
||||
r' | | |',
|
||||
r' | | |',
|
||||
r' _/ | \_',
|
||||
r'|____|____|',
|
||||
],
|
||||
[
|
||||
r' _!_',
|
||||
r' (!_!)',
|
||||
r' \|/',
|
||||
r' \ | /',
|
||||
r' | | |',
|
||||
r' | | |',
|
||||
r' _\ | /_',
|
||||
r'|____|____|',
|
||||
],
|
||||
],
|
||||
MonsterCategory.crypto => [
|
||||
[
|
||||
r' ___',
|
||||
r' <(! !)~',
|
||||
r' / \',
|
||||
r' / \',
|
||||
r' | |',
|
||||
r' | |',
|
||||
r' <__ __>',
|
||||
r'|___ ___|',
|
||||
],
|
||||
[
|
||||
r' ___',
|
||||
r' (! !)>',
|
||||
r' \ /',
|
||||
r' \ /',
|
||||
r' | |',
|
||||
r' | |',
|
||||
r' <__ __>',
|
||||
r'|___ ___|',
|
||||
],
|
||||
],
|
||||
MonsterCategory.ai => [
|
||||
[
|
||||
r' _____',
|
||||
r' / ! \',
|
||||
r' ( ! )',
|
||||
r' ( ! )',
|
||||
r' ( ! )',
|
||||
r' \ /',
|
||||
r' \ /',
|
||||
r' \_/',
|
||||
],
|
||||
[
|
||||
r' ___',
|
||||
r' / ! \',
|
||||
r' / ! \',
|
||||
r' { ! }',
|
||||
r' { ! }',
|
||||
r' \ /',
|
||||
r' \ /',
|
||||
r' \_/',
|
||||
],
|
||||
],
|
||||
MonsterCategory.boss => [
|
||||
[
|
||||
r' ^!^',
|
||||
r' /|!|\',
|
||||
r' /|\',
|
||||
r' / | \',
|
||||
r' | | |',
|
||||
r' V | V',
|
||||
r' _/ | \_',
|
||||
r'|_____|_____|',
|
||||
],
|
||||
[
|
||||
r' ^!^',
|
||||
r' \|!|/',
|
||||
r' \|/',
|
||||
r' \ | /',
|
||||
r' | | |',
|
||||
r' v | v',
|
||||
r' _\ | /_',
|
||||
r'|_____|_____|',
|
||||
],
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||
|
||||
/// 아이템 희귀도와 애니메이션 색상 간의 매핑
|
||||
@@ -1,7 +1,7 @@
|
||||
// 종족별 ASCII 캐릭터 프레임 데이터
|
||||
// 모든 캐릭터는 3줄 × 6자 폭으로 통일 (보스 10줄과 대비)
|
||||
|
||||
import 'package:asciineverdie/src/core/animation/character_frames.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/character_frames.dart';
|
||||
|
||||
/// 종족별 캐릭터 프레임 저장소
|
||||
class RaceCharacterFrames {
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:asciineverdie/src/core/animation/weapon_category.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/weapon_category.dart';
|
||||
|
||||
/// 무기 카테고리별 공격 이펙트 ASCII 프레임
|
||||
///
|
||||
131
lib/src/splash_screen.dart
Normal file
131
lib/src/splash_screen.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 스플래시 화면 (세이브 파일 확인 중) - 레트로 스타일
|
||||
class SplashScreen extends StatelessWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: RetroColors.deepBrown,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 타이틀 로고
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.panelBg,
|
||||
border: Border.all(color: RetroColors.gold, width: 3),
|
||||
),
|
||||
child: const Column(
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, size: 32, color: RetroColors.gold),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'ASCII',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 22,
|
||||
color: RetroColors.gold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: RetroColors.goldDark,
|
||||
offset: Offset(2, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'NEVER DIE',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 16,
|
||||
color: RetroColors.cream,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: RetroColors.brown,
|
||||
offset: Offset(1, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// 레트로 로딩 바
|
||||
const SizedBox(width: 160, child: _RetroLoadingBar()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 레트로 스타일 로딩 바 (애니메이션)
|
||||
class _RetroLoadingBar extends StatefulWidget {
|
||||
const _RetroLoadingBar();
|
||||
|
||||
@override
|
||||
State<_RetroLoadingBar> createState() => _RetroLoadingBarState();
|
||||
}
|
||||
|
||||
class _RetroLoadingBarState extends State<_RetroLoadingBar>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
vsync: this,
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const segmentCount = 10;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.panelBg,
|
||||
border: Border.all(color: RetroColors.panelBorderOuter, width: 2),
|
||||
),
|
||||
child: Row(
|
||||
children: List.generate(segmentCount, (index) {
|
||||
final progress = _controller.value * segmentCount;
|
||||
final distance = (index - progress).abs();
|
||||
final isLit = distance < 2 || (segmentCount - distance) < 2;
|
||||
final opacity = isLit ? 1.0 : 0.2;
|
||||
|
||||
return Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.gold.withValues(alpha: opacity),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user