Compare commits

..

10 Commits

Author SHA1 Message Date
JiWoong Sul
fc15198c57 chore(android): Play Core ProGuard 경고 억제 규칙 추가
- deferred components 관련 dontwarn 규칙 추가
2026-02-23 16:47:51 +09:00
JiWoong Sul
c56e76b176 test: 스킬 서비스 테스트 업데이트
- import 경로 변경 반영
2026-02-23 15:49:50 +09:00
JiWoong Sul
dadd25837d docs: CLAUDE.md 및 감사 보고서 업데이트
- CLAUDE.md 아키텍처 문서 최신화
- 감사 보고서 수정 사항 반영
2026-02-23 15:49:46 +09:00
JiWoong Sul
e13e8032d9 chore(build): 빌드 설정 업데이트
- Android: proguard 규칙 추가, build.gradle 업데이트
- iOS: 권한 설정 및 프로젝트 구성 업데이트
- macOS: 앱 정보 및 entitlements 업데이트
2026-02-23 15:49:43 +09:00
JiWoong Sul
864a866039 refactor(ui): 위젯 분리 및 화면 개선
- game_play_screen에서 desktop 패널 위젯 분리
- death_overlay에서 death_buttons, death_combat_log 분리
- mobile_carousel_layout에서 mobile_options_menu 분리
- 아레나 위젯 개선 (arena_hp_bar, result_panel 등)
- settings_screen에서 retro_settings_widgets 분리
- 기타 위젯 리팩토링 및 import 경로 업데이트
2026-02-23 15:49:38 +09:00
JiWoong Sul
6ddbf23816 feat(app): 테마 시스템 및 스플래시 화면 추가
- AppTheme 클래스 분리 (app_theme.dart)
- 스플래시 화면 추가 (splash_screen.dart)
- app.dart 경량화
2026-02-23 15:49:32 +09:00
JiWoong Sul
1a8858a3b1 feat(l10n): 다국어 리소스 확장
- 아레나, 통계, 설정 등 신규 번역 키 추가
- 한국어, 영어, 일본어 리소스 업데이트
- 생성된 localizations 파일 반영
2026-02-23 15:49:28 +09:00
JiWoong Sul
faaa5af54e refactor(model): 통계 모델 분리
- game_statistics에서 cumulative_statistics, session_statistics 분리
- task_info import 경로 업데이트
2026-02-23 15:49:23 +09:00
JiWoong Sul
68284323c8 refactor(engine): 엔진 서비스 분리 및 리팩토링
- progress_service에서 death_handler, loot_handler, task_generator 분리
- combat_tick_service에서 player_attack_processor 분리
- arena_service에서 arena_combat_simulator 분리
- skill_service에서 skill_auto_selector 분리
2026-02-23 15:49:20 +09:00
JiWoong Sul
8f351df0b6 refactor(shared): animation, l10n, theme 모듈을 core에서 shared로 이동
- core/animation → shared/animation
- core/l10n → shared/l10n
- core/constants/ascii_colors → shared/theme/ascii_colors
- import 경로 업데이트
2026-02-23 15:49:14 +09:00
103 changed files with 9196 additions and 7262 deletions

View File

@@ -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)
- 네트워크 접근 도입
- 원본 데이터/알고리즘 수정
- 대규모 파일 삭제 또는 구조 변경
## 커밋 규칙

View File

@@ -48,6 +48,12 @@ android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
}

34
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,34 @@
# 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
# Play Core (deferred components) 경고 억제
-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication
-dontwarn com.google.android.play.core.splitinstall.SplitInstallException
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManager
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManagerFactory
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest$Builder
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest
-dontwarn com.google.android.play.core.splitinstall.SplitInstallSessionState
-dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener
-dontwarn com.google.android.play.core.tasks.OnFailureListener
-dontwarn com.google.android.play.core.tasks.OnSuccessListener
-dontwarn com.google.android.play.core.tasks.Task

View File

@@ -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 -->

View File

@@ -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 원작 저작권 관련 법률 검토

View File

@@ -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 = (

View File

@@ -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>

View File

@@ -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" }
}

View File

@@ -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": "警告"
}

View File

@@ -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": "경고"
}

View File

@@ -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> {

View File

@@ -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';
}

View File

@@ -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 => '警告';
}

View File

@@ -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 => '경고';
}

View File

@@ -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
View 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

View File

@@ -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';

View File

@@ -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 {

View 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;
}
}

View File

@@ -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 베팅 슬롯 선택

View File

@@ -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({

View 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,
);
}
}

View 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);
}
}

View 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,
);
}
}

View File

@@ -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,
);
}
}

View 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;
}
}

View File

@@ -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;
}
// ============================================================================

View 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);
}
}

View 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,
);
}
}

View File

@@ -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,
);
}
}

View 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,
);
}
}

View File

@@ -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 값들에 대응)

View File

@@ -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),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View 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),
);
}
}

View File

@@ -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 상태 캐릭터 미리보기 위젯

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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 애니메이션 위젯
///

View File

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

View File

@@ -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,
),
],

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;
}
}

View File

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

View 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,
};
}

View 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),
],
),
),
),
),
),
],
);
},
);
}
}

View 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,
),
),
],
);
}
}

View 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 ?? ''),
),
};
}
}

View File

@@ -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) {

View 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);
}
}

View 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,
),
),
],
),
);
}
}

View 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;
}
}

View 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,
),
),
],
),
);
},
);
}
}

View File

@@ -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 토글 버튼 하나만 표시

View File

@@ -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';

View File

@@ -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),
],
),
),
),
),
),
],
),
);
},
);
}
}

View 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),
),
);
}
}

View File

@@ -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,
};
}
}

View 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,
),
),
),
);
}),
),
);
}
}

View File

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

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';
/// 종족 미리보기 위젯
///

View File

@@ -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';

View 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),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -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),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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

View File

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

View File

@@ -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 )
///

View 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();
}
}

View File

@@ -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용
///

View File

@@ -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용 /
///

View File

@@ -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용
///

View 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'********'],
];

View 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'|_____|_____|',
],
],
};
}

View File

@@ -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';
///

View File

@@ -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 {

View File

@@ -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
View 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