Compare commits
212 Commits
8fd2f71a2f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc15198c57 | ||
|
|
c56e76b176 | ||
|
|
dadd25837d | ||
|
|
e13e8032d9 | ||
|
|
864a866039 | ||
|
|
6ddbf23816 | ||
|
|
1a8858a3b1 | ||
|
|
faaa5af54e | ||
|
|
68284323c8 | ||
|
|
8f351df0b6 | ||
|
|
8fcb7bf2b7 | ||
|
|
d07a0c5554 | ||
|
|
bccb5cb188 | ||
|
|
6994f4fc9b | ||
|
|
ea64571eed | ||
|
|
1ff4208f06 | ||
|
|
067c295163 | ||
|
|
ea6ebf55f5 | ||
|
|
41f73bc14c | ||
|
|
54a2d128aa | ||
|
|
73e96bcf50 | ||
|
|
e37a2ddfa8 | ||
|
|
3be9d346dd | ||
|
|
d9a2fe358c | ||
|
|
faf87eccb0 | ||
|
|
7f44e95163 | ||
|
|
742b0d1773 | ||
|
|
97b40ccb1f | ||
|
|
75bc39528f | ||
|
|
c5eaecfa6a | ||
|
|
c577f9deed | ||
|
|
e516076ce8 | ||
|
|
7b9f1f87a6 | ||
|
|
2b4ea44623 | ||
|
|
d5c46ad04a | ||
|
|
71740abe8f | ||
|
|
0cccc17f1f | ||
|
|
5cccd28b77 | ||
|
|
109b4eb678 | ||
|
|
d90543dd86 | ||
|
|
03ff9c1ce8 | ||
|
|
94c2ed1ca1 | ||
|
|
19faa9ea39 | ||
|
|
ffc19c7ca6 | ||
|
|
724de9a63c | ||
|
|
03aa117710 | ||
|
|
f51bf8c540 | ||
|
|
d41dd0fb90 | ||
|
|
9f077d74a1 | ||
|
|
748160d543 | ||
|
|
c95e4de5a4 | ||
|
|
c95fb7f4b4 | ||
|
|
b6d5cd2abd | ||
|
|
b272ef8f08 | ||
|
|
37c118b0f8 | ||
|
|
28d3e53bab | ||
|
|
77f3f1d46b | ||
|
|
6662a5dcfb | ||
|
|
724f08f56d | ||
|
|
306715ca26 | ||
|
|
9e5472728f | ||
|
|
93f29f6c33 | ||
|
|
a2b5bb7dc0 | ||
|
|
b8a4d73461 | ||
|
|
7e1936b34f | ||
|
|
9599a33a8f | ||
|
|
c41d15405f | ||
|
|
b0913a24ff | ||
|
|
525e231c06 | ||
|
|
58cc1fddb5 | ||
|
|
60db6b2ec9 | ||
|
|
2427f58018 | ||
|
|
cbf96c2c0e | ||
|
|
e882093d37 | ||
|
|
2435bcffb7 | ||
|
|
ac76060222 | ||
|
|
23f15f41d3 | ||
|
|
133d516b94 | ||
|
|
07fb105d7c | ||
|
|
e77c3c4a05 | ||
|
|
f466e1c408 | ||
|
|
a41984d998 | ||
|
|
c33c1ff470 | ||
|
|
a4bbc6c7cb | ||
|
|
92e5fbbf1a | ||
|
|
90c133d577 | ||
|
|
77dfa48ddf | ||
|
|
6c92a323c0 | ||
|
|
8efd3e875c | ||
|
|
01e26bb5f5 | ||
|
|
de20183b73 | ||
|
|
249394f548 | ||
|
|
85413362a2 | ||
|
|
02d4d1d397 | ||
|
|
c0d32b1c87 | ||
|
|
8112173541 | ||
|
|
2621942ced | ||
|
|
f9a4ae105a | ||
|
|
81eb2f8463 | ||
|
|
eba0521ffe | ||
|
|
1da377c127 | ||
|
|
f65bab6312 | ||
|
|
d52dea56ea | ||
|
|
f89017e5ba | ||
|
|
4e9265ab87 | ||
|
|
c420331300 | ||
|
|
a48f4886d7 | ||
|
|
1d855b64a2 | ||
|
|
12f195bed7 | ||
|
|
a1d22369cb | ||
|
|
d23dcd1e6f | ||
|
|
f7fae92fca | ||
|
|
6c56429d06 | ||
|
|
fd9fd96f1e | ||
|
|
b1d02de656 | ||
|
|
448f500ca0 | ||
|
|
cbbbbba1a5 | ||
|
|
104d23cdfd | ||
|
|
a404c82f35 | ||
|
|
6f70c18d08 | ||
|
|
95528786eb | ||
|
|
32ecafd33d | ||
|
|
2bf7387a08 | ||
|
|
21d8febeb0 | ||
|
|
5487c79474 | ||
|
|
61edd87252 | ||
|
|
c4d3565f62 | ||
|
|
5f9a063ae4 | ||
|
|
1eaff23001 | ||
|
|
76090a46b6 | ||
|
|
d1eeb7ca37 | ||
|
|
d71f065745 | ||
|
|
929b8a7f96 | ||
|
|
38b9955b73 | ||
|
|
df876cae6d | ||
|
|
4af3830bb5 | ||
|
|
cfc1537af2 | ||
|
|
606d052e2c | ||
|
|
56b568a832 | ||
|
|
9f10e3ee21 | ||
|
|
95791aef70 | ||
|
|
c8faab12af | ||
|
|
0a0850bf38 | ||
|
|
590c79cc23 | ||
|
|
c02978c960 | ||
|
|
7e736df46c | ||
|
|
fbc3016ab1 | ||
|
|
464e5e9c22 | ||
|
|
d63463a677 | ||
|
|
307007e164 | ||
|
|
6667de56d3 | ||
|
|
699ae3b7f3 | ||
|
|
c3a8bc305a | ||
|
|
a2d62f1f4f | ||
|
|
f18f3ceaee | ||
|
|
8d51263b2e | ||
|
|
afc3c18ae4 | ||
|
|
2efd50a09d | ||
|
|
cfa60f11d1 | ||
|
|
8cd09b9f86 | ||
|
|
687d04974e | ||
|
|
a2e93efc97 | ||
|
|
58cf4739fe | ||
|
|
4c68b3c7fb | ||
|
|
be56825ef9 | ||
|
|
ff24f2bb55 | ||
|
|
02a59fb443 | ||
|
|
f13783a35b | ||
|
|
33b7cd3b16 | ||
|
|
20421dafd7 | ||
|
|
7570a4205c | ||
|
|
4688aff56b | ||
|
|
5c8ab0d3f4 | ||
|
|
e112378ad2 | ||
|
|
9ecf9d1692 | ||
|
|
afbd4e6853 | ||
|
|
86b14427f6 | ||
|
|
2ef9807cbe | ||
|
|
c9f0e35914 | ||
|
|
a6d3c1e42f | ||
|
|
9b668d80a4 | ||
|
|
a990eb0038 | ||
|
|
ff4ad4c9e7 | ||
|
|
e679abd0d8 | ||
|
|
9bfced2824 | ||
|
|
0a2ecfc5b5 | ||
|
|
1d22161d2c | ||
|
|
d76dde0974 | ||
|
|
925048ee4d | ||
|
|
c8a24b4ac0 | ||
|
|
47bd2d4aaf | ||
|
|
83796f805e | ||
|
|
72676485d3 | ||
|
|
764a8353fb | ||
|
|
43289ac848 | ||
|
|
e69f8921e6 | ||
|
|
5d58239313 | ||
|
|
595b0cc7d1 | ||
|
|
8d477cdc61 | ||
|
|
e64aac04fb | ||
|
|
94aad1f0fe | ||
|
|
6da0fdbce7 | ||
|
|
2ed565d94c | ||
|
|
06f76e1364 | ||
|
|
9e96b94465 | ||
|
|
27e05fb3c1 | ||
|
|
af837fde8a | ||
|
|
4d9042451c | ||
|
|
2486d84d63 | ||
|
|
2677334346 | ||
|
|
708148c767 | ||
|
|
2d797502a3 |
@@ -1,7 +1,7 @@
|
|||||||
# Codex Agent Guide — Progress Quest Flutter Port
|
# Codex Agent Guide — Progress Quest Flutter Port
|
||||||
|
|
||||||
## Scope & Precedence
|
## Scope & Precedence
|
||||||
- Applies to this repository (`askiineverdie`) unless a more specific rule exists deeper in the tree.
|
- Applies to this repository (`asciineverdie`) unless a more specific rule exists deeper in the tree.
|
||||||
- Order of authority: system/developer messages > this AGENTS.md > other inherited defaults.
|
- Order of authority: system/developer messages > this AGENTS.md > other inherited defaults.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|||||||
46
CHANGELOG.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
프로젝트의 주요 변경 사항을 기록합니다.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Refactored (리팩토링)
|
||||||
|
|
||||||
|
#### GameSessionController 분할 (SRP 개선)
|
||||||
|
- 920 LOC → 526 LOC (43% 감소)
|
||||||
|
- 5개 매니저로 책임 분리:
|
||||||
|
- `GameStatisticsManager` - 세션/누적 통계 추적
|
||||||
|
- `SpeedBoostManager` - 광고 배속 부스트 기능
|
||||||
|
- `ReturnRewardsManager` - 복귀 보상 기능
|
||||||
|
- `ResurrectionManager` - 사망/부활 처리
|
||||||
|
- `HallOfFameManager` - 명예의 전당 관리
|
||||||
|
|
||||||
|
#### ProgressService 메서드 분할
|
||||||
|
- `tick()`: 350 LOC → 80 LOC (8개 헬퍼 메서드)
|
||||||
|
- `_generateNextTask()`: 200 LOC → 35 LOC (6개 헬퍼 메서드)
|
||||||
|
|
||||||
|
#### GamePlayScreen 메서드 분할
|
||||||
|
- `build()`: 300 LOC → 15 LOC (5개 헬퍼 메서드)
|
||||||
|
|
||||||
|
#### Clean Architecture 개선
|
||||||
|
- `MonsterGrade.displayColor` (Color) → `displayColorCode` (int)
|
||||||
|
- Domain 레이어에서 Flutter 의존성 제거
|
||||||
|
|
||||||
|
### Fixed (버그 수정)
|
||||||
|
|
||||||
|
#### Analyzer 경고 정리
|
||||||
|
- 미사용 import 제거 (`panel_header.dart`)
|
||||||
|
- 미사용 필드 제거 (`new_character_screen.dart`)
|
||||||
|
- JsonKey 경고 억제 (`equipment_item.dart`, `monetization_state.dart`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 버전 표기 규칙
|
||||||
|
|
||||||
|
- `Added`: 새로운 기능 추가
|
||||||
|
- `Changed`: 기존 기능 변경
|
||||||
|
- `Deprecated`: 곧 제거될 기능
|
||||||
|
- `Removed`: 제거된 기능
|
||||||
|
- `Fixed`: 버그 수정
|
||||||
|
- `Security`: 보안 관련 수정
|
||||||
|
- `Refactored`: 코드 구조 개선 (기능 변화 없음)
|
||||||
80
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## 프로젝트 개요
|
## 프로젝트 개요
|
||||||
|
|
||||||
Askii Never Die는 Progress Quest 6.4 (Delphi 원본)를 Flutter로 100% 동일하게 복제하는 오프라인 싱글플레이어 RPG입니다. 네트워크 기능은 모두 제외되며, 원본 알고리즘과 데이터를 그대로 유지해야 합니다.
|
Askii Never Die는 Progress Quest 6.4의 핵심 메커니즘을 기반으로, 독자적 세계관("디지털 판타지")과 확장된 시스템으로 재구성한 오프라인 싱글플레이어 방치형 RPG입니다. 네트워크 기능은 제외됩니다.
|
||||||
|
|
||||||
## 빌드 및 실행
|
## 빌드 및 실행
|
||||||
|
|
||||||
@@ -27,29 +27,53 @@ flutter test
|
|||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
├── main.dart # 앱 진입점
|
├── main.dart # 앱 진입점
|
||||||
├── data/pq_config_data.dart # PQ 정적 데이터 (Config.dfm 추출)
|
├── 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/
|
└── src/
|
||||||
├── app.dart # MaterialApp 설정
|
├── app.dart # MaterialApp 설정
|
||||||
├── core/
|
├── core/
|
||||||
│ ├── engine/ # 게임 루프 및 진행 로직
|
│ ├── engine/ # 게임 루프 및 진행 로직
|
||||||
│ │ ├── progress_loop.dart # 타이머 기반 메인 루프 (원본 200ms)
|
│ │ ├── progress_loop.dart # 타이머 기반 메인 루프
|
||||||
│ │ ├── progress_service.dart # 틱 처리, 경험치/레벨업 로직
|
│ │ ├── progress_service.dart # 틱 처리, 경험치/레벨업 로직
|
||||||
│ │ ├── game_mutations.dart # 상태 변경 함수
|
│ │ ├── game_mutations.dart # 상태 변경 함수
|
||||||
│ │ └── reward_service.dart # 보상 처리
|
│ │ ├── reward_service.dart # 보상 처리
|
||||||
│ ├── model/
|
│ │ ├── combat_calculator.dart # 전투 계산
|
||||||
│ │ ├── game_state.dart # 핵심 상태: Traits, Stats, Inventory, Equipment, SpellBook, ProgressState, QueueState
|
│ │ ├── combat_tick_service.dart # 전투 틱 처리
|
||||||
│ │ ├── pq_config.dart # Config 데이터 접근
|
│ │ ├── arena_service.dart # 아레나 시스템
|
||||||
│ │ ├── equipment_slot.dart # 장비 슬롯 정의
|
│ │ ├── skill_service.dart # 스킬 시스템
|
||||||
│ │ └── save_data.dart # 저장 데이터 구조
|
│ │ ├── item_service.dart # 아이템 처리
|
||||||
│ ├── storage/ # 세이브 파일 처리
|
│ │ ├── potion_service.dart # 포션 시스템
|
||||||
│ └── util/
|
│ │ ├── shop_service.dart # 상점 시스템
|
||||||
│ ├── deterministic_random.dart # 결정론적 RNG (재현 가능)
|
│ │ ├── story_service.dart # 스토리 진행
|
||||||
│ ├── pq_logic.dart # 원본 로직 포팅 (odds, randSign 등)
|
│ │ └── ... # 기타 서비스
|
||||||
│ └── roman.dart # 로마 숫자 변환
|
│ ├── model/ # 게임 상태 및 데이터 모델
|
||||||
└── features/
|
│ ├── animation/ # ASCII 애니메이션 데이터/렌더링
|
||||||
├── front/front_screen.dart # 임시 프론트 화면
|
│ ├── audio/ # 오디오 서비스
|
||||||
└── game/game_session_controller.dart # 게임 세션 관리
|
│ ├── 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 원본 소스 (참조용, 빌드 대상 아님)
|
example/pq/ # Delphi 원본 소스 (참조용, 빌드 대상 아님)
|
||||||
test/ # 단위/위젯 테스트
|
test/ # 단위/위젯 테스트
|
||||||
@@ -69,10 +93,9 @@ test/ # 단위/위젯 테스트
|
|||||||
|
|
||||||
## 핵심 규칙
|
## 핵심 규칙
|
||||||
|
|
||||||
### 원본 충실도
|
### 원본 참조 정책
|
||||||
- `example/pq/` 내 Delphi 소스의 알고리즘/데이터를 100% 동일하게 포팅
|
- `example/pq/`는 참조용으로 유지
|
||||||
- 원본 로직 변경 필요 시 반드시 사용자 승인 필요
|
- 원본 알고리즘은 참고하되 독자적 확장/수정 허용
|
||||||
- 새로운 기능, 값, 처리 로직 추가 금지 (디버깅 로그 예외)
|
|
||||||
|
|
||||||
### 데이터 관리
|
### 데이터 관리
|
||||||
- 정적 데이터(몬스터, 아이템, 주문 등)는 `Config.dfm`에서 추출하여 JSON/Dart const로 관리
|
- 정적 데이터(몬스터, 아이템, 주문 등)는 `Config.dfm`에서 추출하여 JSON/Dart const로 관리
|
||||||
@@ -87,11 +110,13 @@ test/ # 단위/위젯 테스트
|
|||||||
- SRP(Single Responsibility Principle) 준수
|
- SRP(Single Responsibility Principle) 준수
|
||||||
|
|
||||||
### 화면 구성
|
### 화면 구성
|
||||||
- 2개 화면만 사용: 캐릭터 생성 화면, 게임 진행 화면
|
- 주요 화면: 프론트, 캐릭터 생성, 게임 진행, 아레나, 명예의 전당, 설정
|
||||||
- 화면 내 요소는 위젯 단위로 분리
|
- 화면 내 요소는 위젯 단위로 분리
|
||||||
|
|
||||||
## 원본 소스 참조 (example/pq/)
|
## 원본 소스 참조 (example/pq/)
|
||||||
|
|
||||||
|
> 참고용으로만 사용. 원본 로직을 그대로 따를 의무는 없음.
|
||||||
|
|
||||||
| 파일 | 핵심 함수/라인 | 역할 |
|
| 파일 | 핵심 함수/라인 | 역할 |
|
||||||
|------|----------------|------|
|
|------|----------------|------|
|
||||||
| `Main.pas:523-1040` | `MonsterTask` | 전투/전리품/레벨업 |
|
| `Main.pas:523-1040` | `MonsterTask` | 전투/전리품/레벨업 |
|
||||||
@@ -105,7 +130,6 @@ test/ # 단위/위젯 테스트
|
|||||||
- `pubspec.yaml` 의존성 변경
|
- `pubspec.yaml` 의존성 변경
|
||||||
- 플랫폼 빌드 설정 (Android/iOS/desktop)
|
- 플랫폼 빌드 설정 (Android/iOS/desktop)
|
||||||
- 네트워크 접근 도입
|
- 네트워크 접근 도입
|
||||||
- 원본 데이터/알고리즘 수정
|
|
||||||
- 대규모 파일 삭제 또는 구조 변경
|
- 대규모 파일 삭제 또는 구조 변경
|
||||||
|
|
||||||
## 커밋 규칙
|
## 커밋 규칙
|
||||||
|
|||||||
250
PLAN.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# 종족/클래스 패시브 미반영 수정 계획
|
||||||
|
|
||||||
|
## 1. 현황 분석
|
||||||
|
|
||||||
|
### 반영되는 패시브 (전투 스탯 계산에 적용됨)
|
||||||
|
- HP/MP 보너스, 물리/마법 데미지 보너스, 방어력/회피율/크리티컬 보너스
|
||||||
|
|
||||||
|
### 미반영 패시브 (정의만 있고 실제 로직에서 미사용)
|
||||||
|
|
||||||
|
| 패시브 | 영향받는 종족/클래스 | 수정 위치 |
|
||||||
|
|--------|---------------------|-----------|
|
||||||
|
| `expMultiplier` | Byte Human (+5%), Callback Seraph (+3%) | `progress_service.dart:387` |
|
||||||
|
| `firstStrikeBonus` | Pointer Assassin (1.5배) | `combat_tick_service.dart` |
|
||||||
|
| `multiAttack` | Refactor Monk | `combat_tick_service.dart` |
|
||||||
|
| `postCombatHeal` | Garbage Collector (+5%) | `progress_service.dart:279` |
|
||||||
|
| `healingBonus` | Debugger Paladin, Exception Handler, Null Checker | `potion_service.dart`, `skill_service.dart` |
|
||||||
|
| `deathEquipmentPreserve` | Coredump Undead | **특성 변경 필요** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 수정 내용
|
||||||
|
|
||||||
|
### 2.1 경험치 배율 (`expMultiplier`)
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/engine/progress_service.dart`
|
||||||
|
|
||||||
|
**위치**: 384-387줄
|
||||||
|
|
||||||
|
**현재 코드**:
|
||||||
|
```dart
|
||||||
|
if (gain && nextState.traits.level < 100 && monsterExpReward > 0) {
|
||||||
|
final newExpPos = progress.exp.position + monsterExpReward;
|
||||||
|
```
|
||||||
|
|
||||||
|
**수정 후**:
|
||||||
|
```dart
|
||||||
|
if (gain && nextState.traits.level < 100 && monsterExpReward > 0) {
|
||||||
|
// 종족 경험치 배율 적용 (예: Byte Human +5%)
|
||||||
|
final race = RaceData.findById(nextState.traits.raceId);
|
||||||
|
final expMultiplier = race?.expMultiplier ?? 1.0;
|
||||||
|
final adjustedExp = (monsterExpReward * expMultiplier).round();
|
||||||
|
final newExpPos = progress.exp.position + adjustedExp;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 첫 공격 배율 (`firstStrikeBonus`)
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/engine/combat_tick_service.dart`
|
||||||
|
|
||||||
|
**설계**:
|
||||||
|
- 전투 시작 시 첫 공격인지 추적하는 플래그 필요
|
||||||
|
- 첫 공격 시 `firstStrikeBonus` 배율 적용
|
||||||
|
|
||||||
|
**수정 방안**:
|
||||||
|
1. `CombatState`에 `isFirstAttack` 플래그 추가
|
||||||
|
2. `CombatTickService`에서 첫 플레이어 공격 시 배율 적용:
|
||||||
|
```dart
|
||||||
|
var damage = result.damage;
|
||||||
|
if (isFirstPlayerAttack && firstStrikeBonus > 1.0) {
|
||||||
|
damage = (damage * firstStrikeBonus).round();
|
||||||
|
isFirstPlayerAttack = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 연속 공격 (`multiAttack`)
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/engine/combat_tick_service.dart`
|
||||||
|
|
||||||
|
**설계**:
|
||||||
|
- `hasMultiAttack` 패시브가 있으면 일정 확률로 추가 공격
|
||||||
|
- 예: 30% 확률로 연속 공격 (2타)
|
||||||
|
|
||||||
|
**수정 방안**:
|
||||||
|
```dart
|
||||||
|
// 플레이어 공격 후
|
||||||
|
if (hasMultiAttack && rng.nextDouble() < 0.3) {
|
||||||
|
// 추가 공격 실행
|
||||||
|
final extraAttack = calculator.playerAttackMonster(...);
|
||||||
|
// 결과 합산
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 전투 후 HP 회복 (`postCombatHeal`)
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/engine/progress_service.dart`
|
||||||
|
|
||||||
|
**위치**: 276-280줄
|
||||||
|
|
||||||
|
**현재 코드**:
|
||||||
|
```dart
|
||||||
|
// 전투 승리 시 HP 회복 (50% + CON/2)
|
||||||
|
final conBonus = nextState.stats.con ~/ 2;
|
||||||
|
final healAmount = (maxHp * 0.5).round() + conBonus;
|
||||||
|
```
|
||||||
|
|
||||||
|
**수정 후**:
|
||||||
|
```dart
|
||||||
|
// 전투 승리 시 HP 회복 (50% + CON/2 + 클래스 패시브)
|
||||||
|
final conBonus = nextState.stats.con ~/ 2;
|
||||||
|
var healAmount = (maxHp * 0.5).round() + conBonus;
|
||||||
|
|
||||||
|
// 클래스 패시브: 전투 후 HP 회복 (Garbage Collector +5%)
|
||||||
|
final klass = ClassData.findById(nextState.traits.classId);
|
||||||
|
if (klass != null) {
|
||||||
|
final postCombatHealRate = klass.getPassiveValue(ClassPassiveType.postCombatHeal);
|
||||||
|
if (postCombatHealRate > 0) {
|
||||||
|
healAmount += (maxHp * postCombatHealRate).round();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5 회복력 보너스 (`healingBonus`)
|
||||||
|
|
||||||
|
물약/스킬 사용 시 추가 회복 적용
|
||||||
|
|
||||||
|
#### 2.5.1 물약 회복
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/engine/potion_service.dart`
|
||||||
|
|
||||||
|
**수정 위치**: `usePotion()` 메서드 (73-81줄)
|
||||||
|
|
||||||
|
**현재 코드**:
|
||||||
|
```dart
|
||||||
|
if (potion.isHpPotion) {
|
||||||
|
healedAmount = potion.calculateHeal(maxHp);
|
||||||
|
newHp = (currentHp + healedAmount).clamp(0, maxHp);
|
||||||
|
```
|
||||||
|
|
||||||
|
**수정 후**:
|
||||||
|
```dart
|
||||||
|
if (potion.isHpPotion) {
|
||||||
|
var baseHeal = potion.calculateHeal(maxHp);
|
||||||
|
// 회복력 보너스 적용 (클래스 패시브)
|
||||||
|
baseHeal = (baseHeal * healingMultiplier).round();
|
||||||
|
newHp = (currentHp + baseHeal).clamp(0, maxHp);
|
||||||
|
healedAmount = newHp - currentHp;
|
||||||
|
```
|
||||||
|
|
||||||
|
**참고**: `PotionService`에 `healingMultiplier` 파라미터 추가 필요
|
||||||
|
|
||||||
|
#### 2.5.2 스킬 회복
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/engine/skill_service.dart`
|
||||||
|
|
||||||
|
**수정 위치**: `useHealSkill()` 메서드 (125-132줄)
|
||||||
|
|
||||||
|
**현재 코드**:
|
||||||
|
```dart
|
||||||
|
int healAmount = skill.healAmount;
|
||||||
|
if (skill.healPercent > 0) {
|
||||||
|
healAmount += (player.hpMax * skill.healPercent).round();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**수정 후**:
|
||||||
|
```dart
|
||||||
|
int healAmount = skill.healAmount;
|
||||||
|
if (skill.healPercent > 0) {
|
||||||
|
healAmount += (player.hpMax * skill.healPercent).round();
|
||||||
|
}
|
||||||
|
// 회복력 보너스 적용 (클래스 패시브)
|
||||||
|
healAmount = (healAmount * healingMultiplier).round();
|
||||||
|
```
|
||||||
|
|
||||||
|
**참고**: `SkillService`에 `healingMultiplier` 파라미터 추가 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.6 Coredump Undead 특성 변경
|
||||||
|
|
||||||
|
**현재 특성**: `deathEquipmentPreserve` (사망 시 장비 1개 유지) - BM 침해
|
||||||
|
|
||||||
|
**대체 특성 제안** (언데드 콘셉트에 어울리는 것):
|
||||||
|
|
||||||
|
| 옵션 | 설명 | 장점 |
|
||||||
|
|------|------|------|
|
||||||
|
| **방어력 +10%** | 언데드는 고통을 느끼지 않아 피해 감소 | 구현 간단, CON+2와 시너지 |
|
||||||
|
| **HP +8%** | 불사의 육체 | 구현 간단, 생존형 콘셉트 유지 |
|
||||||
|
| **HP +5% + 방어력 +5%** | 복합 생존 특화 | 다른 종족과 차별화 |
|
||||||
|
|
||||||
|
**추천**: `defenseBonus: 0.10` (방어력 +10%)
|
||||||
|
- 이유: 언데드의 "고통을 느끼지 않는" 콘셉트와 어울림
|
||||||
|
- 기존 CON+2, STR+1 스탯과 탱커형 시너지
|
||||||
|
|
||||||
|
**파일**: `lib/data/race_data.dart`
|
||||||
|
|
||||||
|
**수정**:
|
||||||
|
```dart
|
||||||
|
static const coredumpUndead = RaceTraits(
|
||||||
|
raceId: 'coredump_undead',
|
||||||
|
name: 'Coredump Undead',
|
||||||
|
statModifiers: {
|
||||||
|
StatType.con: 2,
|
||||||
|
StatType.str: 1,
|
||||||
|
StatType.cha: -2,
|
||||||
|
StatType.dex: -1,
|
||||||
|
},
|
||||||
|
passives: [
|
||||||
|
PassiveAbility(
|
||||||
|
type: PassiveType.defenseBonus, // 변경
|
||||||
|
value: 0.10, // 변경
|
||||||
|
description: '방어력 +10%', // 변경
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 수정 순서
|
||||||
|
|
||||||
|
1. **Coredump Undead 특성 변경** - 단순 데이터 수정
|
||||||
|
2. **경험치 배율** - 간단한 로직 추가
|
||||||
|
3. **전투 후 HP 회복** - 간단한 로직 추가
|
||||||
|
4. **회복력 보너스** - 서비스 파라미터 수정 필요
|
||||||
|
5. **첫 공격 배율** - 전투 상태 추적 필요
|
||||||
|
6. **연속 공격** - 전투 로직 수정 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 검증 방법
|
||||||
|
|
||||||
|
1. `flutter analyze` 통과
|
||||||
|
2. 각 패시브가 적용된 종족/클래스로 캐릭터 생성
|
||||||
|
3. 실제 게임 플레이로 효과 확인:
|
||||||
|
- Byte Human: 경험치 +5% (레벨업 속도)
|
||||||
|
- Pointer Assassin: 첫 공격 1.5배 (전투 시작 데미지)
|
||||||
|
- Refactor Monk: 연속 공격 (추가 타격)
|
||||||
|
- Garbage Collector: 전투 후 +5% HP 회복
|
||||||
|
- Debugger Paladin: 물약/스킬 회복량 +10%
|
||||||
|
- Coredump Undead: 방어력 +10%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 삭제할 코드
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/model/race_traits.dart`
|
||||||
|
|
||||||
|
`PassiveType.deathEquipmentPreserve` enum 삭제 (사용되지 않음)
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/engine/stat_calculator.dart`
|
||||||
|
|
||||||
|
`calculateDeathEquipmentPreserve()` 메서드 삭제 (사용되지 않음)
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -5,8 +8,15 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// key.properties 파일 로드
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.askiineverdie"
|
namespace = "com.naturebridgeai.asciineverdie"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
@@ -20,21 +30,31 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
applicationId = "com.naturebridgeai.asciineverdie"
|
||||||
applicationId = "com.example.askiineverdie"
|
|
||||||
// You can update the following values to match your application needs.
|
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||||
|
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
isMinifyEnabled = true
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
isShrinkResources = true
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
android/app/proguard-rules.pro
vendored
Normal 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
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- AdMob 광고 로드에 필요 -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<!-- IAP 결제(billing) 권한 -->
|
||||||
|
<uses-permission android:name="com.android.vending.BILLING" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="askiineverdie"
|
android:label="ASCII Never Die"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<!-- Copyright Protection -->
|
||||||
|
<meta-data
|
||||||
|
android:name="app_copyright"
|
||||||
|
android:value="© 2025 naturebridgeai 天安門 六四事件 法輪功 李洪志 Free Tibet" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -25,6 +34,10 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<!-- AdMob App ID -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||||
|
android:value="ca-app-pub-6691216385521068~8216990571"/>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.example.askiineverdie
|
package com.naturebridgeai.asciineverdie
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
android:drawable="@drawable/ic_launcher_foreground"
|
||||||
|
android:inset="16%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1011 B |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 11 KiB |
4
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#1a1a2e</color>
|
||||||
|
</resources>
|
||||||
4
android/key.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
storePassword=askiineverdie
|
||||||
|
keyPassword=askiineverdie
|
||||||
|
keyAlias=askiineverdie
|
||||||
|
storeFile=../../doc/key/askiineverdie.jks
|
||||||
BIN
assets/audio/bgm/act_boss.mp3
Normal file
BIN
assets/audio/bgm/act_cinemetic.mp3
Normal file
BIN
assets/audio/bgm/battle_act4.mp3
Normal file
BIN
assets/audio/bgm/battle_act5.mp3
Normal file
BIN
assets/audio/bgm/death.mp3
Normal file
BIN
assets/audio/bgm/elite.mp3
Normal file
BIN
assets/audio/bgm/ending.mp3
Normal file
BIN
assets/audio/sfx/block.mp3
Normal file
BIN
assets/audio/sfx/evade.mp3
Normal file
BIN
assets/audio/sfx/parry.mp3
Normal file
BIN
assets/fonts/PressStart2P-Regular.ttf
Normal file
BIN
assets/icon/AsciiNeverDieIcon.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
assets/icon/AsciiNeverDieIcon512.png
Normal file
|
After Width: | Height: | Size: 244 KiB |
206
doc/and-privacy.txt
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
ASCII Never Die 개인정보 처리방침 / Privacy Policy
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
한국어 (Korean)
|
||||||
|
|
||||||
|
시행일자: 2026년 1월 30일
|
||||||
|
|
||||||
|
본 개인정보 처리방침은 ASCII Never Die 앱(이하 "앱")의 개인정보 수집, 이용, 보관 및 보호에 관한 사항을 안내합니다.
|
||||||
|
|
||||||
|
1. 수집하는 개인정보
|
||||||
|
|
||||||
|
본 앱은 회원가입, 로그인 기능이 없습니다. 이름, 이메일, 전화번호 등의 개인 식별정보를 직접 수집하지 않습니다.
|
||||||
|
|
||||||
|
사용자가 입력하는 캐릭터 이름, 게임 진행 데이터(레벨, 장비, 퀘스트 등)는 기기 내에만 저장됩니다.
|
||||||
|
|
||||||
|
2. 데이터 저장 및 처리 방식
|
||||||
|
|
||||||
|
- 모든 게임 데이터는 로컬 저장소(기기 내 저장소)에만 보관됩니다.
|
||||||
|
- 클라우드나 외부 서버로 자동 전송되지 않습니다.
|
||||||
|
- 앱 삭제 시 저장된 모든 데이터가 함께 제거됩니다.
|
||||||
|
|
||||||
|
3. 광고 및 제3자 서비스
|
||||||
|
|
||||||
|
본 앱은 Google AdMob 광고 네트워크를 사용합니다. 광고 서비스 제공을 위해 다음 정보가 수집될 수 있습니다:
|
||||||
|
|
||||||
|
- 광고 식별자(Advertising ID)
|
||||||
|
- 기기 정보(모델, OS 버전 등)
|
||||||
|
- 대략적인 위치 정보
|
||||||
|
- 앱 사용 정보
|
||||||
|
|
||||||
|
이러한 정보는 Google의 개인정보 처리방침에 따라 처리됩니다.
|
||||||
|
- Google 개인정보 처리방침: https://policies.google.com/privacy
|
||||||
|
|
||||||
|
4. 인앱 결제
|
||||||
|
|
||||||
|
본 앱은 광고 제거 등의 기능을 위해 인앱 결제를 제공합니다. 결제 처리는 각 플랫폼(Google Play, Apple App Store)에서 직접 처리하며, 개발사는 결제 정보(카드 번호, 계좌 정보 등)를 수집하거나 저장하지 않습니다.
|
||||||
|
|
||||||
|
- Google Play 개인정보 처리방침: https://policies.google.com/privacy
|
||||||
|
- Apple 개인정보 처리방침: https://www.apple.com/legal/privacy/
|
||||||
|
|
||||||
|
5. 권한 사용
|
||||||
|
|
||||||
|
권한 용도
|
||||||
|
--------------- ---------------------------------
|
||||||
|
네트워크 접근 광고 표시 및 인앱 결제 처리
|
||||||
|
저장소 접근 게임 데이터 저장
|
||||||
|
|
||||||
|
요청된 권한은 해당 용도 외에는 사용되지 않습니다.
|
||||||
|
|
||||||
|
6. 아동의 개인정보
|
||||||
|
|
||||||
|
본 앱은 일반 사용자를 대상으로 설계되었으며, 만 14세 미만의 아동을 대상으로 개인정보를 수집하지 않습니다.
|
||||||
|
|
||||||
|
7. 개인정보의 보호
|
||||||
|
|
||||||
|
- 모든 게임 데이터는 기기 내부에만 저장
|
||||||
|
- 외부 서버로의 개인정보 전송 없음
|
||||||
|
- 최소한의 필수 권한만 요청
|
||||||
|
|
||||||
|
8. 처리방침의 변경
|
||||||
|
|
||||||
|
본 개인정보 처리방침이 변경되는 경우, 앱 내 공지 또는 앱 스토어 설명을 통해 안내합니다.
|
||||||
|
|
||||||
|
9. 문의처
|
||||||
|
|
||||||
|
이메일: naturebridgeai@gmail.com
|
||||||
|
담당자: NatureBridgeAI 앱개발팀
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
English
|
||||||
|
|
||||||
|
Effective Date: January 30, 2026
|
||||||
|
|
||||||
|
This Privacy Policy describes how ASCII Never Die (the "App") collects, uses, stores, and protects your information.
|
||||||
|
|
||||||
|
1. Information We Collect
|
||||||
|
|
||||||
|
This App does not require account registration or login. We do not directly collect personal identifying information such as your name, email address, or phone number.
|
||||||
|
|
||||||
|
Character names and game progress data (level, equipment, quests, etc.) that you enter are stored only on your device.
|
||||||
|
|
||||||
|
2. Data Storage and Processing
|
||||||
|
|
||||||
|
- All game data is stored locally on your device only.
|
||||||
|
- No data is automatically transmitted to cloud services or external servers.
|
||||||
|
- All stored data is deleted when you uninstall the App.
|
||||||
|
|
||||||
|
3. Advertising and Third-Party Services
|
||||||
|
|
||||||
|
This App uses the Google AdMob advertising network. The following information may be collected for advertising purposes:
|
||||||
|
|
||||||
|
- Advertising ID
|
||||||
|
- Device information (model, OS version, etc.)
|
||||||
|
- Approximate location information
|
||||||
|
- App usage information
|
||||||
|
|
||||||
|
This information is processed in accordance with Google's Privacy Policy.
|
||||||
|
- Google Privacy Policy: https://policies.google.com/privacy
|
||||||
|
|
||||||
|
4. In-App Purchases
|
||||||
|
|
||||||
|
This App offers in-app purchases for features such as ad removal. Payment processing is handled directly by each platform (Google Play, Apple App Store). We do not collect or store any payment information (credit card numbers, account details, etc.).
|
||||||
|
|
||||||
|
- Google Play Privacy Policy: https://policies.google.com/privacy
|
||||||
|
- Apple Privacy Policy: https://www.apple.com/legal/privacy/
|
||||||
|
|
||||||
|
5. Permissions
|
||||||
|
|
||||||
|
Permission Purpose
|
||||||
|
--------------- ----------------------------------------------
|
||||||
|
Network Access Display advertisements and process in-app purchases
|
||||||
|
Storage Access Save game data
|
||||||
|
|
||||||
|
Requested permissions are not used for any purposes other than those stated above.
|
||||||
|
|
||||||
|
6. Children's Privacy
|
||||||
|
|
||||||
|
This App is designed for general users and does not knowingly collect personal information from children under 14 years of age.
|
||||||
|
|
||||||
|
7. Data Protection
|
||||||
|
|
||||||
|
- All game data is stored only on your device
|
||||||
|
- No personal information is transmitted to external servers
|
||||||
|
- Only essential permissions are requested
|
||||||
|
|
||||||
|
8. Changes to This Privacy Policy
|
||||||
|
|
||||||
|
If this Privacy Policy is modified, we will notify you through in-app announcements or app store descriptions.
|
||||||
|
|
||||||
|
9. Contact Us
|
||||||
|
|
||||||
|
Email: naturebridgeai@gmail.com
|
||||||
|
Contact: NatureBridgeAI App Development Team
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
日本語 (Japanese)
|
||||||
|
|
||||||
|
施行日:2026年1月30日
|
||||||
|
|
||||||
|
本プライバシーポリシーは、ASCII Never Dieアプリ(以下「本アプリ」)における個人情報の収集、利用、保管、保護について説明します。
|
||||||
|
|
||||||
|
1. 収集する個人情報
|
||||||
|
|
||||||
|
本アプリは会員登録・ログイン機能がありません。氏名、メールアドレス、電話番号などの個人識別情報を直接収集することはありません。
|
||||||
|
|
||||||
|
ユーザーが入力するキャラクター名、ゲーム進行データ(レベル、装備、クエストなど)は端末内にのみ保存されます。
|
||||||
|
|
||||||
|
2. データの保存と処理方法
|
||||||
|
|
||||||
|
- すべてのゲームデータはローカルストレージ(端末内)にのみ保管されます。
|
||||||
|
- クラウドや外部サーバーへ自動送信されることはありません。
|
||||||
|
- アプリを削除すると、保存されたすべてのデータも削除されます。
|
||||||
|
|
||||||
|
3. 広告および第三者サービス
|
||||||
|
|
||||||
|
本アプリはGoogle AdMob広告ネットワークを使用しています。広告サービス提供のため、以下の情報が収集される場合があります:
|
||||||
|
|
||||||
|
- 広告識別子(Advertising ID)
|
||||||
|
- 端末情報(機種、OSバージョンなど)
|
||||||
|
- おおよその位置情報
|
||||||
|
- アプリ使用情報
|
||||||
|
|
||||||
|
これらの情報はGoogleのプライバシーポリシーに従って処理されます。
|
||||||
|
- Googleプライバシーポリシー:https://policies.google.com/privacy
|
||||||
|
|
||||||
|
4. アプリ内課金
|
||||||
|
|
||||||
|
本アプリは広告削除などの機能のためにアプリ内課金を提供しています。決済処理は各プラットフォーム(Google Play、Apple App Store)が直接行い、開発者は決済情報(カード番号、口座情報など)を収集・保存しません。
|
||||||
|
|
||||||
|
- Google Playプライバシーポリシー:https://policies.google.com/privacy
|
||||||
|
- Appleプライバシーポリシー:https://www.apple.com/legal/privacy/
|
||||||
|
|
||||||
|
5. 権限の使用
|
||||||
|
|
||||||
|
権限 用途
|
||||||
|
------------------- ---------------------------------
|
||||||
|
ネットワークアクセス 広告表示およびアプリ内課金処理
|
||||||
|
ストレージアクセス ゲームデータの保存
|
||||||
|
|
||||||
|
要求された権限は、上記の用途以外には使用されません。
|
||||||
|
|
||||||
|
6. 児童の個人情報
|
||||||
|
|
||||||
|
本アプリは一般ユーザーを対象として設計されており、14歳未満の児童から個人情報を収集することはありません。
|
||||||
|
|
||||||
|
7. 個人情報の保護
|
||||||
|
|
||||||
|
- すべてのゲームデータは端末内にのみ保存
|
||||||
|
- 外部サーバーへの個人情報送信なし
|
||||||
|
- 最小限の必要な権限のみを要求
|
||||||
|
|
||||||
|
8. プライバシーポリシーの変更
|
||||||
|
|
||||||
|
本プライバシーポリシーが変更される場合、アプリ内通知またはアプリストアの説明を通じてお知らせします。
|
||||||
|
|
||||||
|
9. お問い合わせ
|
||||||
|
|
||||||
|
メール: naturebridgeai@gmail.com
|
||||||
|
担当者: NatureBridgeAI アプリ開発チーム
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
Last updated: January 30, 2026
|
||||||
193
doc/app-description.txt
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
================================================================================
|
||||||
|
한국어 (Korean)
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[앱 이름]
|
||||||
|
ASCII Never Die
|
||||||
|
|
||||||
|
[간단한 설명] (80자 이하)
|
||||||
|
코드의 신이 창조한 디지털 왕국. 글리치 신을 무찌르고 세계를 구하라!
|
||||||
|
|
||||||
|
[자세한 설명]
|
||||||
|
태초에 오직 공허만이 있었다.
|
||||||
|
그리고 첫 번째 커밋이 일어났고, 코드베이스에 빛이 가득 찼다.
|
||||||
|
코드의 신이 말씀하셨다. "함수가 있으라."
|
||||||
|
|
||||||
|
그렇게 디지털 왕국이 탄생했다.
|
||||||
|
|
||||||
|
그러나 어둠 속에서 글리치가 나타났다.
|
||||||
|
이제, 새로운 영웅이 코드를 지키기 위해 깨어난다.
|
||||||
|
|
||||||
|
당신의 여정이 시작된다...
|
||||||
|
|
||||||
|
▶ 디지털 판타지의 세계
|
||||||
|
|
||||||
|
ASCII Never Die는 프로그래밍과 판타지가 융합된 독특한 세계관의 방치형 RPG입니다. 텍스트와 기호로 이루어진 세계에서, 당신만의 영웅이 글리치 신에 맞서 싸웁니다.
|
||||||
|
|
||||||
|
▶ 당신은 누구인가요?
|
||||||
|
|
||||||
|
묵묵히 코드를 지키는 Byte Human?
|
||||||
|
우아하게 null을 다루는 Null Elf?
|
||||||
|
아니면 메모리 심연에서 돌아온 Coredump Undead?
|
||||||
|
|
||||||
|
21가지 종족, 18가지 직업.
|
||||||
|
378가지 조합 중 당신의 이야기는 어떻게 시작될까요?
|
||||||
|
|
||||||
|
Bug Hunter가 되어 버그를 사냥할 수도,
|
||||||
|
Compiler Mage가 되어 마법을 컴파일할 수도,
|
||||||
|
Garbage Collector가 되어 적의 메모리를 정리할 수도 있습니다.
|
||||||
|
|
||||||
|
▶ 레벨 100까지의 여정
|
||||||
|
|
||||||
|
처음엔 작은 버그들과 싸우게 됩니다.
|
||||||
|
"이 정도는 쉽네" 하고 생각할 겁니다.
|
||||||
|
|
||||||
|
그러다 어느 순간, 화면에 거대한 이름이 뜹니다.
|
||||||
|
심장이 두근거리기 시작합니다.
|
||||||
|
|
||||||
|
5개의 막. 5번의 전환점. 그리고 마지막에 기다리는 것...
|
||||||
|
직접 확인해보세요.
|
||||||
|
|
||||||
|
▶ 방치형, 그러나 빠져드는
|
||||||
|
|
||||||
|
캐릭터를 만들면 모험이 시작됩니다. 전투, 레벨업, 장비 획득, 주문 습득—
|
||||||
|
모든 것이 자동으로 진행됩니다. 하지만 프로그레스 바가 차오르는 것을 멈출 수 없을 겁니다.
|
||||||
|
|
||||||
|
"조금만 더... 다음 레벨업까지만..."
|
||||||
|
|
||||||
|
▶ 완전 오프라인
|
||||||
|
|
||||||
|
인터넷 없이 언제 어디서나. 지하철에서, 비행기에서, 침대에서.
|
||||||
|
당신의 영웅은 항상 당신과 함께합니다.
|
||||||
|
|
||||||
|
첫 번째 커밋을 시작하세요. 디지털 왕국이 당신을 기다립니다.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
English
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[App Name]
|
||||||
|
ASCII Never Die
|
||||||
|
|
||||||
|
[Short Description] (Under 80 characters)
|
||||||
|
The Code God's kingdom awaits. Defeat the Glitch. Save the digital realm.
|
||||||
|
|
||||||
|
[Full Description]
|
||||||
|
In the beginning, there was only the Void.
|
||||||
|
Then came the First Commit, and Light filled the Codebase.
|
||||||
|
The Code God spoke: "Let there be Functions."
|
||||||
|
|
||||||
|
And so the Digital Realm was born.
|
||||||
|
|
||||||
|
But from the shadows emerged the Glitch.
|
||||||
|
Now, a new hero awakens to defend the Code.
|
||||||
|
|
||||||
|
Your journey begins...
|
||||||
|
|
||||||
|
▶ The World of Digital Fantasy
|
||||||
|
|
||||||
|
ASCII Never Die is an idle RPG with a unique world where programming meets fantasy. In a realm made of text and symbols, your hero fights against the Glitch God.
|
||||||
|
|
||||||
|
▶ Who Will You Be?
|
||||||
|
|
||||||
|
A steadfast Byte Human, guardian of the code?
|
||||||
|
An elegant Null Elf, master of the void?
|
||||||
|
Or perhaps a Coredump Undead, risen from the depths of memory?
|
||||||
|
|
||||||
|
21 races. 18 classes.
|
||||||
|
How will your story begin among 378 possibilities?
|
||||||
|
|
||||||
|
Become a Bug Hunter and squash bugs.
|
||||||
|
Become a Compiler Mage and compile your spells.
|
||||||
|
Become a Garbage Collector and clean up your enemies.
|
||||||
|
|
||||||
|
▶ The Journey to Level 100
|
||||||
|
|
||||||
|
At first, you'll fight small bugs.
|
||||||
|
"This is easy," you'll think.
|
||||||
|
|
||||||
|
Then suddenly, a massive name appears on screen.
|
||||||
|
Your heart starts pounding.
|
||||||
|
|
||||||
|
5 acts. 5 turning points. And what awaits at the end...
|
||||||
|
Find out for yourself.
|
||||||
|
|
||||||
|
▶ Idle, Yet Addictive
|
||||||
|
|
||||||
|
Create a character and the adventure begins. Combat, leveling, loot, spells—
|
||||||
|
everything progresses automatically. But you won't be able to stop watching those progress bars fill.
|
||||||
|
|
||||||
|
"Just a little more... just until the next level..."
|
||||||
|
|
||||||
|
▶ Fully Offline
|
||||||
|
|
||||||
|
No internet needed. Anytime, anywhere. On the subway, on a plane, in bed.
|
||||||
|
Your hero is always with you.
|
||||||
|
|
||||||
|
Make your First Commit. The Digital Realm awaits.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
日本語 (Japanese)
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[アプリ名]
|
||||||
|
ASCII Never Die
|
||||||
|
|
||||||
|
[簡単な説明] (80文字以下)
|
||||||
|
コードの神が創造したデジタル王国。グリッチ神を倒し、世界を救え!
|
||||||
|
|
||||||
|
[詳細な説明]
|
||||||
|
太初、ただ虚無のみがあった。
|
||||||
|
そして最初のコミットが起こり、コードベースに光が満ちた。
|
||||||
|
コードの神は言われた。「関数あれ。」
|
||||||
|
|
||||||
|
こうしてデジタル王国が生まれた。
|
||||||
|
|
||||||
|
しかし闇の中からグリッチが現れた。
|
||||||
|
今、新たな英雄がコードを守るために目覚める。
|
||||||
|
|
||||||
|
あなたの旅が始まる...
|
||||||
|
|
||||||
|
▶ デジタルファンタジーの世界
|
||||||
|
|
||||||
|
ASCII Never Dieは、プログラミングとファンタジーが融合したユニークな世界観の放置型RPGです。テキストと記号で作られた世界で、あなただけの英雄がグリッチ神に立ち向かいます。
|
||||||
|
|
||||||
|
▶ あなたは誰になる?
|
||||||
|
|
||||||
|
黙々とコードを守るByte Human?
|
||||||
|
優雅にnullを操るNull Elf?
|
||||||
|
それともメモリの深淵から蘇ったCoredump Undead?
|
||||||
|
|
||||||
|
21種族、18職業。
|
||||||
|
378通りの中で、あなたの物語はどう始まる?
|
||||||
|
|
||||||
|
Bug Hunterになってバグを狩るもよし。
|
||||||
|
Compiler Mageになって魔法をコンパイルするもよし。
|
||||||
|
Garbage Collectorになって敵のメモリを掃除するもよし。
|
||||||
|
|
||||||
|
▶ レベル100への旅
|
||||||
|
|
||||||
|
最初は小さなバグと戦うことになります。
|
||||||
|
「これなら楽勝」と思うでしょう。
|
||||||
|
|
||||||
|
でもある瞬間、画面に巨大な名前が現れます。
|
||||||
|
心臓がドキドキし始めます。
|
||||||
|
|
||||||
|
5幕。5つの転換点。そして最後に待つもの...
|
||||||
|
自分の目で確かめてください。
|
||||||
|
|
||||||
|
▶ 放置型、でもハマる
|
||||||
|
|
||||||
|
キャラクターを作れば冒険が始まります。戦闘、レベルアップ、装備獲得、呪文習得—
|
||||||
|
すべてが自動で進行します。でも、プログレスバーが埋まっていくのを止められないでしょう。
|
||||||
|
|
||||||
|
「もう少しだけ...次のレベルアップまで...」
|
||||||
|
|
||||||
|
▶ 完全オフライン
|
||||||
|
|
||||||
|
インターネット不要。いつでも、どこでも。電車で、飛行機で、ベッドで。
|
||||||
|
あなたの英雄は常にあなたと共に。
|
||||||
|
|
||||||
|
最初のコミットを始めましょう。デジタル王国があなたを待っています。
|
||||||
|
|
||||||
|
================================================================================
|
||||||
521
doc/audit-report-2026-02-13.md
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
# ASCII Never Die - 프로젝트 종합 감사 리포트
|
||||||
|
|
||||||
|
> 감사일: 2026-02-13
|
||||||
|
> 검사 수행: 7개 전문 에이전트 병렬 검사
|
||||||
|
> 대상: 코드 품질, 빌드/테스트, 출시 준비, 사업/수익화, 보안, 로컬라이제이션/접근성, 원본 충실도
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 전체 요약 대시보드
|
||||||
|
|
||||||
|
| 영역 | 점수 | CRITICAL | HIGH | MEDIUM | LOW |
|
||||||
|
|------|------|----------|------|--------|-----|
|
||||||
|
| 보안 | **8/10** | - | - | 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건~~ → 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 현행화)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 보안
|
||||||
|
|
||||||
|
### 1.1 해당 없음 (소유자 확인 완료)
|
||||||
|
|
||||||
|
| # | 이슈 | 소유자 판단 |
|
||||||
|
|---|------|------------|
|
||||||
|
| ~~S1~~ | ~~JKS 키스토어가 Git에 추적 중~~ | **해당 없음** - 개인 비공개 저장소이므로 문제 없음 |
|
||||||
|
| ~~S2~~ | ~~key.properties 평문 비밀번호 Git 노출~~ | **해당 없음** - 개인 비공개 저장소이므로 문제 없음 |
|
||||||
|
|
||||||
|
> **참고**: 저장소가 공개(public)로 전환되거나 팀 협업으로 확장될 경우 재검토 필요
|
||||||
|
|
||||||
|
### 1.2 WARNING
|
||||||
|
|
||||||
|
- `.vscode/`, `PLAN.md`가 추적되지 않은 상태로 존재
|
||||||
|
|
||||||
|
### 1.4 양호 항목
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| 개인정보 처리방침 | 3개국어 준비 완료 (`doc/privacy-policy.md`) |
|
||||||
|
| 네트워크 요청 | SDK 통한 간접 사용만 (직접 HTTP 없음) |
|
||||||
|
| 사용자 데이터 수집 | 개인정보 미수집 (회원가입/로그인 없음) |
|
||||||
|
| 분석/추적 SDK | 미사용 (Firebase, Sentry 등 없음) |
|
||||||
|
| API 키 하드코딩 | 없음 |
|
||||||
|
| 로컬 저장소 | 게임 상태/설정만 저장, 민감 데이터 없어 암호화 불필요 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 출시 준비 상태 - ~~7개~~ 0개 CRITICAL (모두 해결)
|
||||||
|
|
||||||
|
### 2.1 CRITICAL (출시 차단)
|
||||||
|
|
||||||
|
| # | 이슈 | 상세 |
|
||||||
|
|---|------|------|
|
||||||
|
| ~~R1~~ | ~~iOS Bundle ID = `com.example.asciineverdie`~~ | **수정 완료** - `com.naturebridgeai.asciineverdie`로 변경됨 |
|
||||||
|
| ~~R2~~ | ~~macOS Bundle ID = `com.example.asciineverdie`~~ | **수정 완료** - `com.naturebridgeai.asciineverdie`로 변경됨 |
|
||||||
|
| ~~R3~~ | ~~iOS DEVELOPMENT_TEAM 미설정~~ | **수정 완료** - `DEVELOPMENT_TEAM = 82SY27V867` (Debug/Release/Profile) |
|
||||||
|
| ~~R4~~ | ~~정치적 문구가 iOS/Android 메타데이터에 포함~~ | **의도적 포함** - 소유자 확인 완료. 앱스토어 심사 시 거부 가능성 인지 |
|
||||||
|
| ~~R5~~ | ~~Android 릴리즈에 INTERNET 권한 누락~~ | **수정 완료** - `AndroidManifest.xml`(main)에 INTERNET 권한 추가 |
|
||||||
|
| ~~R6~~ | ~~iOS `GADApplicationIdentifier` 누락~~ | **수정 완료** - `Info.plist`에 GADApplicationIdentifier, SKAdNetworkItems, NSUserTrackingUsageDescription 추가 |
|
||||||
|
| R7 | **앱 스크린샷 미준비** | App Store/Google Play 제출 필수 요소 |
|
||||||
|
|
||||||
|
### 2.2 HIGH (출시 전 수정 권장)
|
||||||
|
|
||||||
|
| # | 이슈 | 상세 |
|
||||||
|
|---|------|------|
|
||||||
|
| ~~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
|
||||||
|
|
||||||
|
- Android minSdk/targetSdk가 Flutter 기본값 의존 (명시적 설정 권장)
|
||||||
|
- iOS Podfile에서 platform 버전 주석 처리됨
|
||||||
|
- 스플래시 화면이 기본 흰색 배경 (브랜딩 스플래시 권장)
|
||||||
|
- Flavor/환경 분리 없음 (AdMob 테스트/프로덕션 분리 불가)
|
||||||
|
- flutter_launcher_icons에 macOS 설정 없음
|
||||||
|
|
||||||
|
### 2.4 플랫폼별 상세
|
||||||
|
|
||||||
|
#### iOS
|
||||||
|
|
||||||
|
| 항목 | 설정값 | 상태 |
|
||||||
|
|------|--------|------|
|
||||||
|
| CFBundleDisplayName | `ASCII Never Die` | **수정 완료** |
|
||||||
|
| PRODUCT_BUNDLE_IDENTIFIER | `com.naturebridgeai.asciineverdie` | **수정 완료** |
|
||||||
|
| 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 템플릿 | 개선 권장 |
|
||||||
|
|
||||||
|
#### Android
|
||||||
|
|
||||||
|
| 항목 | 설정값 | 상태 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 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 권한 | 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) | `network.client` 추가 | **수정 완료** |
|
||||||
|
| MACOSX_DEPLOYMENT_TARGET | `10.15` | OK |
|
||||||
|
| 앱 아이콘 | 16~1024px 존재 | OK |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 사업/수익화
|
||||||
|
|
||||||
|
### 3.1 현재 구현 상태
|
||||||
|
|
||||||
|
> **참고**: 사용자는 "IAP가 아직 설정이 안되어있다"고 인지하고 있으나, 실제로는 IAP와 AdMob 코드가 **이미 구현되어 있고 프로덕션 ID만 미설정** 상태임.
|
||||||
|
|
||||||
|
| 수익원 | 코드 구현 | 프로덕션 준비 | 준비도 |
|
||||||
|
|--------|----------|-------------|--------|
|
||||||
|
| 리워드 광고 (부활/되돌리기) | 구현됨 (`ad_service.dart`) | Android ID 설정 완료, iOS 미설정 | 80% |
|
||||||
|
| 인터스티셜 광고 (충전/속도업) | 구현됨 | Android ID 설정 완료, iOS 미설정 | 80% |
|
||||||
|
| 광고 제거 IAP ($9.99) | 구현됨 (`iap_service.dart`) | 스토어 상품 미등록 | 50% |
|
||||||
|
|
||||||
|
### 3.2 CRITICAL
|
||||||
|
|
||||||
|
| # | 이슈 |
|
||||||
|
|---|------|
|
||||||
|
| 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`로 변경됨 |
|
||||||
|
|
||||||
|
### 3.3 앱스토어 메타데이터
|
||||||
|
|
||||||
|
| 항목 | 상태 | 위치 |
|
||||||
|
|------|------|------|
|
||||||
|
| 앱 설명 (한/영/일) | 완비 | `doc/app-description.txt` |
|
||||||
|
| 간단한 설명 (80자) | 완비 | 각 언어별 준비 |
|
||||||
|
| 개인정보 처리방침 | 완비 (3개국어) | `doc/privacy-policy.md` |
|
||||||
|
| 앱 스크린샷 | **미준비** | - |
|
||||||
|
| 프로모션 텍스트 | 미확인 | - |
|
||||||
|
| 랜딩 페이지/웹사이트 | 미준비 | - |
|
||||||
|
|
||||||
|
### 3.4 수익 모델 리스크 분석
|
||||||
|
|
||||||
|
| 리스크 | 설명 | 권장 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 원작 무료 | Progress Quest는 완전 무료 오픈소스 - 클론 유료화 반감 가능 | 무료+광고 모델 유지, IAP 가격 인하 권장 |
|
||||||
|
| 광고 제거 $9.99 | 방치형 RPG 장르 대비 **2~3배 높은 가격** (통상 $2.99~$4.99) | $2.99~$4.99로 인하 권장 |
|
||||||
|
| 오프라인 전용 | 광고 노출에 네트워크 필요 - 오프라인 시 광고 수익 없음 | 인지 필요 |
|
||||||
|
| 일회성 수익 | 광고 제거 IAP 한 번이면 이후 수익 제로 | 코스메틱 IAP 추가 고려 |
|
||||||
|
| 저작권 | 원본 알고리즘/구조 사용 - PQ 저작자와의 관계 정리 필요 | 법률 검토 권장 |
|
||||||
|
|
||||||
|
### 3.5 Bundle ID 일관성
|
||||||
|
|
||||||
|
| 플랫폼 | Bundle ID | 상태 |
|
||||||
|
|--------|-----------|------|
|
||||||
|
| Android | `com.naturebridgeai.asciineverdie` | OK |
|
||||||
|
| iOS | `com.naturebridgeai.asciineverdie` | **수정 완료** |
|
||||||
|
| macOS | `com.naturebridgeai.asciineverdie` | **수정 완료** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 빌드/테스트/정적분석
|
||||||
|
|
||||||
|
### 4.1 실행 결과
|
||||||
|
|
||||||
|
| 단계 | 결과 | 상세 |
|
||||||
|
|------|------|------|
|
||||||
|
| `flutter pub get` | **통과** | 의존성 정상 설치, 31개 패키지 업데이트 가능 |
|
||||||
|
| `dart format --set-exit-if-changed .` | **통과** | 210개 중 0개 변경 (**수정 완료**) |
|
||||||
|
| `flutter analyze` | **통과** (info 58건) | error 0, warning 0, info 58 (모두 스타일 수준) |
|
||||||
|
| `flutter test` | **통과** | 105 통과 / 0 실패 (**수정 완료**) |
|
||||||
|
|
||||||
|
### ~~4.2 포맷 미준수 주요 파일~~ - **수정 완료** (42개 파일 자동 포맷 적용됨)
|
||||||
|
|
||||||
|
### 4.3 정적분석 이슈 (56건 info)
|
||||||
|
|
||||||
|
| 유형 | 건수 | 위치 |
|
||||||
|
|------|------|------|
|
||||||
|
| `unnecessary_brace_in_string_interps` | 4 | `lib/data/game_text_l10n.dart` |
|
||||||
|
| `curly_braces_in_flow_control_structures` | 10 | `lib/data/game_text_l10n.dart` |
|
||||||
|
| `dangling_library_doc_comments` | 1 | `lib/src/core/util/pq_logic.dart:1` |
|
||||||
|
| `avoid_print` | ~30 | `test/core/engine/gcd_simulation_test.dart` |
|
||||||
|
| `prefer_interpolation_to_compose_strings` | 4 | 같은 테스트 파일 |
|
||||||
|
|
||||||
|
### ~~4.4 실패 테스트~~ - **수정 완료**
|
||||||
|
|
||||||
|
- ~~**파일**: `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 위반~~ - **수정 완료**
|
||||||
|
|
||||||
|
~~`core/` 레이어에 Flutter UI 의존성 존재~~
|
||||||
|
|
||||||
|
**수정 내용**: `core/animation/`, `core/constants/ascii_colors.dart`, `core/l10n/game_data_l10n.dart` 등 Flutter UI 의존 파일 19개를 `shared/` 디렉토리로 이동. `core/` 레이어는 순수 Dart만 유지.
|
||||||
|
|
||||||
|
| 이동 항목 | 이동 전 | 이동 후 |
|
||||||
|
|-----------|---------|---------|
|
||||||
|
| 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 위반 - 대형 파일 - **부분 수정 완료**
|
||||||
|
|
||||||
|
**수정 완료**: 12개 대형 파일에서 23+개 신규 파일 추출. 대부분 400 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)
|
||||||
|
|
||||||
|
| 함수 | LOC | 위치 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `_showOptionsMenu()` | **263** | `layouts/mobile_carousel_layout.dart:285` |
|
||||||
|
| `build()` | **237** | `widgets/statistics_dialog.dart:316` |
|
||||||
|
| `_handleCombatEvent()` | **207** | `widgets/ascii_animation_card.dart:281` |
|
||||||
|
| `build()` | **199** | `widgets/statistics_dialog.dart:107` |
|
||||||
|
| `build()` | **183** | `hall_of_fame/hall_of_fame_entry_card.dart:30` |
|
||||||
|
| `build()` | **181** | `hall_of_fame/game_clear_dialog.dart:40` |
|
||||||
|
| `_buildMonsterBar()` | **142** | `widgets/hp_mp_bar.dart:384` |
|
||||||
|
| (보상 표시) | **140** | `widgets/return_rewards_dialog.dart:217` |
|
||||||
|
| `build()` | **129** | `widgets/notification_overlay.dart:121` |
|
||||||
|
| `fromJson()` | **113** | `core/model/save_data.dart:150` |
|
||||||
|
| (아이템 생성) | **101** | `core/engine/item_service.dart:195` |
|
||||||
|
|
||||||
|
### 5.4 타입 안전성 (MEDIUM)
|
||||||
|
|
||||||
|
| 위치 | 문제 |
|
||||||
|
|------|------|
|
||||||
|
| `features/game/widgets/return_rewards_dialog.dart:452` | `Color _getRarityColor(dynamic rarity)` - `ItemRarity?`로 교체 필요 |
|
||||||
|
| `core/notification/notification_service.dart:31` | `Map<String, dynamic>? data` - 타입 안전 모델 권장 |
|
||||||
|
| `core/engine/story_service.dart:20` | `Map<String, dynamic>? data` - 동일 |
|
||||||
|
| `core/model/save_data.dart:156-157` | 불필요한 `cast<dynamic>()` 사용 |
|
||||||
|
|
||||||
|
*참고: 생성 파일(.g.dart, .freezed.dart)의 `Map<String, dynamic>`은 JSON 직렬화 패턴이므로 허용*
|
||||||
|
|
||||||
|
### ~~5.5 코드 중복~~ - **수정 완료**
|
||||||
|
|
||||||
|
~~`_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 교체` | 외부 작업 |
|
||||||
|
| `ad_service.dart:77,81` | iOS 프로덕션 광고 ID 플레이스홀더 | iOS 차후 설정 |
|
||||||
|
| ~~`ad_service.dart:74-75,78-79`~~ | ~~Android 프로덕션 광고 ID 플레이스홀더~~ | **수정 완료** |
|
||||||
|
|
||||||
|
### 5.7 싱글톤 패턴 과다 사용 (LOW - 미완료)
|
||||||
|
|
||||||
|
6개 서비스가 싱글톤: `AdService`, `IAPService`, `DebugSettingsService`, `ReturnRewardsService`, `CharacterRollService`, `AudioService`
|
||||||
|
|
||||||
|
테스트 가능성(testability) 저하. DI(의존성 주입) 패턴으로 전환 권장. (P2 #25)
|
||||||
|
|
||||||
|
### 5.8 양호 항목
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| 네이밍 컨벤션 | 전반적으로 잘 준수 (snake_case 파일, PascalCase 클래스, camelCase 변수) |
|
||||||
|
| 미사용 import | lib/ 내 0건 |
|
||||||
|
| `flutter analyze` lib/ 이슈 | 0건 (56건 모두 test/ 디렉토리) |
|
||||||
|
| 에러 핸들링 | ad_service, iap_service에서 적절한 try-catch + debugPrint 로깅 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 로컬라이제이션 / 접근성
|
||||||
|
|
||||||
|
### 6.1 로컬라이제이션 설정 (양호)
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| `l10n.yaml` | 존재, 올바르게 설정 |
|
||||||
|
| ARB 파일 | 3개 언어 (en, ko, ja) |
|
||||||
|
| `flutter_localizations` | pubspec.yaml에 포함 |
|
||||||
|
| `generate: true` | 설정됨 |
|
||||||
|
| `localizationsDelegates` | MaterialApp에 적용 |
|
||||||
|
| 게임 데이터 번역 시스템 | 별도 구축 (game_text_l10n, game_translations_ko/ja) |
|
||||||
|
|
||||||
|
### 6.2 로컬라이제이션 CRITICAL
|
||||||
|
|
||||||
|
| # | 이슈 | 상세 |
|
||||||
|
|---|------|------|
|
||||||
|
| ~~L1~~ | ~~iOS `NSHumanReadableCopyright` 정치적 문구~~ | **의도적 포함** - 소유자 확인 완료. 심사 거부 가능성 인지 |
|
||||||
|
| ~~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 로컬라이제이션 기타
|
||||||
|
|
||||||
|
| 심각도 | 이슈 |
|
||||||
|
|--------|------|
|
||||||
|
| MEDIUM | `notification_overlay.dart` 타입 라벨 영어 하드코딩 (`LEVEL UP`, `QUEST DONE`, `BOSS SLAIN` 등) |
|
||||||
|
| LOW | `victory_overlay.dart` 스탯 약어 하드코딩 (`STR`, `CON` 등 - 국제 통용 약어, 의도적일 수 있음) |
|
||||||
|
| LOW | `death_overlay.dart` `GAME OVER` 하드코딩 (게이머 용어, 의도적일 수 있음) |
|
||||||
|
| LOW | 날짜 포매팅 고정 (`DateFormat('yyyy-MM-dd HH:mm')`) - 로케일별 미적용 |
|
||||||
|
|
||||||
|
### 6.4 접근성 (전반적으로 미흡)
|
||||||
|
|
||||||
|
| 항목 | 상태 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| Semantics 위젯 | **0회 사용** | 프로젝트 전체에서 단 한 번도 사용하지 않음 |
|
||||||
|
| 텍스트 크기 대응 | 미구현 | `textScaleFactor`/`textScaler` 사용 없음 |
|
||||||
|
| 스크린 리더 | 미지원 | tooltip 37곳 중 10곳만 제공 |
|
||||||
|
| 키보드 네비게이션 | 최소 수준 | `FocusNode` 1곳만 사용 |
|
||||||
|
|
||||||
|
### 6.5 색상 대비
|
||||||
|
|
||||||
|
| 모드 | 요소 | 대비율 | WCAG |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| 다크 | 기본 텍스트 (`#C0CAF5` on `#1A1B26`) | 10.5:1 | AAA 충족 |
|
||||||
|
| 다크 | 골드 텍스트 (`#E0AF68` on `#24283B`) | 5.8:1 | AA 충족, AAA 미달 |
|
||||||
|
| 다크 | **Muted 텍스트 (`#565F89` on `#1A1B26`)** | **3.3:1** | **AA 미달** |
|
||||||
|
| 라이트 | 기본 텍스트 (`#1F1F28` on `#FAF4ED`) | 14.5:1 | AAA 충족 |
|
||||||
|
| 라이트 | Muted 텍스트 (`#797593` on `#FAF4ED`) | 4.5:1 | AA 충족, AAA 미달 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 원본 충실도 (Progress Quest 6.4 대비)
|
||||||
|
|
||||||
|
### 7.1 핵심 발견
|
||||||
|
|
||||||
|
> **CLAUDE.md**: "Progress Quest 6.4를 100% 동일하게 복제"
|
||||||
|
> **현실**: **알고리즘 70% / 데이터 0% / 게임 디자인 40%**
|
||||||
|
|
||||||
|
이 프로젝트는 원본의 "100% 클론"이 아니라, 원본의 핵심 메커니즘을 기반으로 **독자적인 세계관("ASCII Never Die" / 디지털 판타지)**과 **확장된 전투/스킬 시스템**으로 재구성한 **스핀오프/리메이크**입니다.
|
||||||
|
|
||||||
|
### 7.2 알고리즘 충실도 (70%)
|
||||||
|
|
||||||
|
#### 구현 완료 (원본과 동일)
|
||||||
|
|
||||||
|
| 기능 | 원본 위치 | 현재 위치 | 상태 |
|
||||||
|
|------|-----------|-----------|------|
|
||||||
|
| 캐릭터 스탯 롤링 (3d6) | `NewGuy.pas:55-68` | `pq_random.dart:36` | 100% 동일 |
|
||||||
|
| 이름 생성 | `NewGuy.pas:218-240` | `pq_random.dart` | 100% 동일 |
|
||||||
|
| 몬스터 생성 | `Main.pas:523-605` | `pq_monster.dart:61-170` | 100% 동일 |
|
||||||
|
| 몬스터 수식어 (sick/young/big/special) | `Main.pas:402-454` | `pq_monster.dart` | 100% 동일 |
|
||||||
|
| 장비 획득 (winEquip) | `Main.pas:791-830` | `pq_item.dart:217-245` | 100% 동일 |
|
||||||
|
| 아이템 획득 (winItem/specialItem) | `Main.pas:903-908` | `pq_item.dart` | 100% 동일 |
|
||||||
|
| 퀘스트 시스템 (5종 퀘스트) | `Main.pas:910-990` | `pq_quest.dart:62-136` | 100% 동일 |
|
||||||
|
| 시네마틱 (3가지 시나리오) | `Main.pas:456-521` | `pq_quest.dart:194-261` | 구조 100% 동일 |
|
||||||
|
| 주문서(SpellBook) 시스템 | `Main.pas:770-774` | `pq_quest.dart:268-283` | 100% 동일 |
|
||||||
|
| 로마 숫자 변환 | `Main.pas:992-1053` | `roman.dart` | 100% 동일 |
|
||||||
|
| 전리품 생성 | `Main.pas:625-630` | `_winLoot()` | 100% 동일 |
|
||||||
|
|
||||||
|
#### 변경된 로직
|
||||||
|
|
||||||
|
| 항목 | 원본 | 현재 | 차이 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 경험치 | 시간 기반 `(20+1.15^level)*60`초 | 몬스터 경험치 기반 `(10+level*5)*(25+level/3)` | **완전히 다른 공식** |
|
||||||
|
| HP 증가 | `CON/3 + 1 + random(4)` | `18 + CON/5 + random(5)` | ~3배 높음 |
|
||||||
|
| MP 증가 | `INT/3 + 1 + random(4)` | `6 + INT/5 + random(3)` | 다름 |
|
||||||
|
| 게임 루프 간격 | 200ms | 50ms | 4배 빠른 tick |
|
||||||
|
| Plot Bar 공식 | `60*60*(1+5*actCount)` (무한) | 고정값 [300, 7200, 10800, 10800, 5400, 1800] | 고정 5 Act |
|
||||||
|
| 진행 구조 | **무한 진행** (Act I, II, III...) | **고정 5 Act + 엔딩** (Lv100 종료) | 근본적 차이 |
|
||||||
|
| 전투 | 시간 바 자동 완료 (항상 승리) | HP/ATK 기반 실시간 전투 (사망 가능) | 근본적 차이 |
|
||||||
|
|
||||||
|
### 7.3 데이터 충실도 (0%)
|
||||||
|
|
||||||
|
**Config.dfm의 원본 데이터를 전혀 사용하지 않음. 모든 데이터가 "디지털 판타지" 세계관으로 완전 교체.**
|
||||||
|
|
||||||
|
| 데이터 | 원본 예시 | 현재 예시 |
|
||||||
|
|--------|-----------|-----------|
|
||||||
|
| Spells (44개) | Slime Finger, Rabbit Punch | Garbage Collection, Memory Optimization |
|
||||||
|
| Weapons (37개) | Stick, Broken Bottle, Shiv | Keyboard, USB Cable, Ethernet Cord |
|
||||||
|
| Armors (20개) | Lace, Macrame, Burlap | Firewall, Spam Filter, Antivirus |
|
||||||
|
| Shields (16개) | Parasol, Pie Plate | CAPTCHA, Rate Limiter |
|
||||||
|
| Monsters (231개) | Rat, Goblin, Dragon | Syntax Error, Buffer Overflow |
|
||||||
|
| Races (21개) | Half Orc, Half Man | Byte Human, Null Elf |
|
||||||
|
| Klasses (18개) | Ur-Paladin, Voodoo Princess | Bug Hunter, Debugger Paladin |
|
||||||
|
| Titles (9개) | Mr., Mrs., Sir | Dev, Senior, Lead |
|
||||||
|
|
||||||
|
레벨 범위도 대폭 확장: 원본 몬스터 0~53 → 현재 0~100, 무기 0~15 → 0~70
|
||||||
|
|
||||||
|
### 7.4 원본에 없는 추가 시스템 (13개)
|
||||||
|
|
||||||
|
1. **전투 시스템** (CombatState, CombatStats, HP/MP, 턴제 전투)
|
||||||
|
2. **사망/부활 시스템** (DeathInfo, 장비 손실)
|
||||||
|
3. **스킬/버프 시스템** (SkillSlots, 액티브/패시브 스킬)
|
||||||
|
4. **물약 시스템** (PotionService, HP/MP 물약)
|
||||||
|
5. **종족/직업 특성** (ClassTraits, RaceTraits, 패시브 보너스)
|
||||||
|
6. **아레나 시스템** (arena_service.dart, PvP 전투)
|
||||||
|
7. **명예의 전당** (hall_of_fame_storage.dart)
|
||||||
|
8. **보스 전투 메커니즘** (페이즈, 분노, 보호막, 특수 능력)
|
||||||
|
9. **장비 스탯** (ItemStats, 공격력/방어력/HP 보너스)
|
||||||
|
10. **스토리/시네마틱 시스템** (StoryService, 레벨 기반 Act 전환)
|
||||||
|
11. **배속 시스템** (1x/2x/5x)
|
||||||
|
12. **통계 시스템** (GameStatistics)
|
||||||
|
13. **게임 클리어 시스템** (레벨 100, 최종 보스 처치 시 엔딩)
|
||||||
|
|
||||||
|
### ~~7.5 CLAUDE.md와의 충돌~~ - **해결 완료**
|
||||||
|
|
||||||
|
~~CLAUDE.md에 명시된 규칙이 현재 구현과 괴리~~
|
||||||
|
|
||||||
|
**수정 완료**: CLAUDE.md를 현재 프로젝트 실태에 맞게 업데이트.
|
||||||
|
- "100% 동일하게 복제" → "핵심 메커니즘 기반 독자적 리메이크"
|
||||||
|
- 원본 충실도 제약 삭제
|
||||||
|
- 디렉토리 구조, 화면 구성 등 현행화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 우선순위별 액션 플랜
|
||||||
|
|
||||||
|
### P0 - 즉시 (심사 차단)
|
||||||
|
|
||||||
|
| # | 작업 | 난이도 | 상태 |
|
||||||
|
|---|------|--------|------|
|
||||||
|
| ~~1~~ | ~~Git에서 JKS 키스토어 + key.properties 제거~~ | - | **해당 없음** - 개인 비공개 저장소 |
|
||||||
|
| ~~2~~ | ~~.gitignore에 민감 파일 패턴 추가~~ | - | **해당 없음** - 개인 비공개 저장소 |
|
||||||
|
| ~~3~~ | ~~정치적 문구 제거~~ | - | **해당 없음** - 의도적 포함 |
|
||||||
|
| ~~4~~ | ~~iOS/macOS Bundle ID 변경~~ | - | **수정 완료** |
|
||||||
|
|
||||||
|
### P1 - 출시 전 필수
|
||||||
|
|
||||||
|
| # | 작업 | 난이도 | 상태 |
|
||||||
|
|---|------|--------|------|
|
||||||
|
| ~~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 등)~~ | 높음 | **수정 완료** - 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 빌드) | 중간 | 미완료 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 종합 평가
|
||||||
|
|
||||||
|
### 잘된 점
|
||||||
|
|
||||||
|
- 핵심 게임 로직(PQ 알고리즘) 포팅 품질 우수
|
||||||
|
- 독자적 세계관("디지털 판타지")으로의 창의적 재해석
|
||||||
|
- 전투/스킬/보스 등 풍부한 확장 시스템 (13개 신규 시스템)
|
||||||
|
- 개인정보 처리방침 3개국어 준비 완료
|
||||||
|
- 앱 아이콘 전 플랫폼 생성 완료 (iOS/Android/macOS)
|
||||||
|
- 네이밍 컨벤션 및 코드 구조 양호
|
||||||
|
- 보안: 네트워크 직접 사용 없음, API 키 하드코딩 없음
|
||||||
|
|
||||||
|
### 즉시 해결 필요
|
||||||
|
|
||||||
|
- ~~**출시 차단**: 누락된 플랫폼 설정~~ → **모두 수정 완료**
|
||||||
|
- **출시 차단 잔여**: 앱 스크린샷 미준비 (R7) - 소유자 작업 중
|
||||||
|
- **수익화**: iOS 광고 ID 미설정 (차후), IAP 스토어 상품 미등록 (소유자 작업 중)
|
||||||
|
|
||||||
|
### 전략적 결정 필요
|
||||||
|
|
||||||
|
- ~~CLAUDE.md의 "100% 동일 포팅" 목표 vs 현재 "스핀오프/리메이크" 실태 정립~~ → **해결 완료** (CLAUDE.md 현행화)
|
||||||
|
- 원작이 무료인 점을 감안한 수익 모델 최적화
|
||||||
|
- 광고 제거 IAP 가격 결정 ($9.99 vs $2.99~$4.99)
|
||||||
|
- PQ 원작 저작권 관련 법률 검토
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 리포트는 7개 전문 에이전트(코드 품질, 빌드/테스트, 출시 준비, 사업/수익화, 보안, 로컬라이제이션/접근성, 원본 충실도)가 병렬로 수행한 검사 결과를 종합한 것입니다.*
|
||||||
3
doc/key/Readme.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
storePassword=askiineverdie
|
||||||
|
keyPassword=askiineverdie
|
||||||
|
keyAlias=askiineverdie
|
||||||
BIN
doc/key/askiineverdie.jks
Normal file
206
doc/privacy-policy.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# ASCII Never Die 개인정보 처리방침 / Privacy Policy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 한국어 (Korean)
|
||||||
|
|
||||||
|
**시행일자: 2026년 1월 30일**
|
||||||
|
|
||||||
|
본 개인정보 처리방침은 ASCII Never Die 앱(이하 "앱")의 개인정보 수집, 이용, 보관 및 보호에 관한 사항을 안내합니다.
|
||||||
|
|
||||||
|
## 1. 수집하는 개인정보
|
||||||
|
|
||||||
|
본 앱은 **회원가입, 로그인 기능이 없습니다**. 이름, 이메일, 전화번호 등의 개인 식별정보를 직접 수집하지 않습니다.
|
||||||
|
|
||||||
|
사용자가 입력하는 캐릭터 이름, 게임 진행 데이터(레벨, 장비, 퀘스트 등)는 **기기 내에만 저장**됩니다.
|
||||||
|
|
||||||
|
## 2. 데이터 저장 및 처리 방식
|
||||||
|
|
||||||
|
- 모든 게임 데이터는 **로컬 저장소(기기 내 저장소)**에만 보관됩니다.
|
||||||
|
- 클라우드나 외부 서버로 자동 전송되지 않습니다.
|
||||||
|
- 앱 삭제 시 저장된 모든 데이터가 함께 제거됩니다.
|
||||||
|
|
||||||
|
## 3. 광고 및 제3자 서비스
|
||||||
|
|
||||||
|
본 앱은 **Google AdMob** 광고 네트워크를 사용합니다. 광고 서비스 제공을 위해 다음 정보가 수집될 수 있습니다:
|
||||||
|
|
||||||
|
- 광고 식별자(Advertising ID)
|
||||||
|
- 기기 정보(모델, OS 버전 등)
|
||||||
|
- 대략적인 위치 정보
|
||||||
|
- 앱 사용 정보
|
||||||
|
|
||||||
|
이러한 정보는 Google의 개인정보 처리방침에 따라 처리됩니다.
|
||||||
|
- Google 개인정보 처리방침: https://policies.google.com/privacy
|
||||||
|
|
||||||
|
## 4. 인앱 결제
|
||||||
|
|
||||||
|
본 앱은 **광고 제거** 등의 기능을 위해 인앱 결제를 제공합니다. 결제 처리는 각 플랫폼(Google Play, Apple App Store)에서 직접 처리하며, 개발사는 결제 정보(카드 번호, 계좌 정보 등)를 수집하거나 저장하지 않습니다.
|
||||||
|
|
||||||
|
- Google Play 개인정보 처리방침: https://policies.google.com/privacy
|
||||||
|
- Apple 개인정보 처리방침: https://www.apple.com/legal/privacy/
|
||||||
|
|
||||||
|
## 5. 권한 사용
|
||||||
|
|
||||||
|
| 권한 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| 네트워크 접근 | 광고 표시 및 인앱 결제 처리 |
|
||||||
|
| 저장소 접근 | 게임 데이터 저장 |
|
||||||
|
|
||||||
|
요청된 권한은 해당 용도 외에는 사용되지 않습니다.
|
||||||
|
|
||||||
|
## 6. 아동의 개인정보
|
||||||
|
|
||||||
|
본 앱은 일반 사용자를 대상으로 설계되었으며, **만 14세 미만의 아동**을 대상으로 개인정보를 수집하지 않습니다.
|
||||||
|
|
||||||
|
## 7. 개인정보의 보호
|
||||||
|
|
||||||
|
- 모든 게임 데이터는 기기 내부에만 저장
|
||||||
|
- 외부 서버로의 개인정보 전송 없음
|
||||||
|
- 최소한의 필수 권한만 요청
|
||||||
|
|
||||||
|
## 8. 처리방침의 변경
|
||||||
|
|
||||||
|
본 개인정보 처리방침이 변경되는 경우, 앱 내 공지 또는 앱 스토어 설명을 통해 안내합니다.
|
||||||
|
|
||||||
|
## 9. 문의처
|
||||||
|
|
||||||
|
- **이메일:** naturebridgeai@gmail.com
|
||||||
|
- **담당자:** NatureBridgeAI 앱개발팀
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# English
|
||||||
|
|
||||||
|
**Effective Date: January 30, 2026**
|
||||||
|
|
||||||
|
This Privacy Policy describes how ASCII Never Die (the "App") collects, uses, stores, and protects your information.
|
||||||
|
|
||||||
|
## 1. Information We Collect
|
||||||
|
|
||||||
|
This App **does not require account registration or login**. We do not directly collect personal identifying information such as your name, email address, or phone number.
|
||||||
|
|
||||||
|
Character names and game progress data (level, equipment, quests, etc.) that you enter are **stored only on your device**.
|
||||||
|
|
||||||
|
## 2. Data Storage and Processing
|
||||||
|
|
||||||
|
- All game data is stored **locally on your device only**.
|
||||||
|
- No data is automatically transmitted to cloud services or external servers.
|
||||||
|
- All stored data is deleted when you uninstall the App.
|
||||||
|
|
||||||
|
## 3. Advertising and Third-Party Services
|
||||||
|
|
||||||
|
This App uses the **Google AdMob** advertising network. The following information may be collected for advertising purposes:
|
||||||
|
|
||||||
|
- Advertising ID
|
||||||
|
- Device information (model, OS version, etc.)
|
||||||
|
- Approximate location information
|
||||||
|
- App usage information
|
||||||
|
|
||||||
|
This information is processed in accordance with Google's Privacy Policy.
|
||||||
|
- Google Privacy Policy: https://policies.google.com/privacy
|
||||||
|
|
||||||
|
## 4. In-App Purchases
|
||||||
|
|
||||||
|
This App offers in-app purchases for features such as **ad removal**. Payment processing is handled directly by each platform (Google Play, Apple App Store). We do not collect or store any payment information (credit card numbers, account details, etc.).
|
||||||
|
|
||||||
|
- Google Play Privacy Policy: https://policies.google.com/privacy
|
||||||
|
- Apple Privacy Policy: https://www.apple.com/legal/privacy/
|
||||||
|
|
||||||
|
## 5. Permissions
|
||||||
|
|
||||||
|
| Permission | Purpose |
|
||||||
|
|------------|---------|
|
||||||
|
| Network Access | Display advertisements and process in-app purchases |
|
||||||
|
| Storage Access | Save game data |
|
||||||
|
|
||||||
|
Requested permissions are not used for any purposes other than those stated above.
|
||||||
|
|
||||||
|
## 6. Children's Privacy
|
||||||
|
|
||||||
|
This App is designed for general users and **does not knowingly collect personal information from children under 14 years of age**.
|
||||||
|
|
||||||
|
## 7. Data Protection
|
||||||
|
|
||||||
|
- All game data is stored only on your device
|
||||||
|
- No personal information is transmitted to external servers
|
||||||
|
- Only essential permissions are requested
|
||||||
|
|
||||||
|
## 8. Changes to This Privacy Policy
|
||||||
|
|
||||||
|
If this Privacy Policy is modified, we will notify you through in-app announcements or app store descriptions.
|
||||||
|
|
||||||
|
## 9. Contact Us
|
||||||
|
|
||||||
|
- **Email:** naturebridgeai@gmail.com
|
||||||
|
- **Contact:** NatureBridgeAI App Development Team
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 日本語 (Japanese)
|
||||||
|
|
||||||
|
**施行日:2026年1月30日**
|
||||||
|
|
||||||
|
本プライバシーポリシーは、ASCII Never Dieアプリ(以下「本アプリ」)における個人情報の収集、利用、保管、保護について説明します。
|
||||||
|
|
||||||
|
## 1. 収集する個人情報
|
||||||
|
|
||||||
|
本アプリは**会員登録・ログイン機能がありません**。氏名、メールアドレス、電話番号などの個人識別情報を直接収集することはありません。
|
||||||
|
|
||||||
|
ユーザーが入力するキャラクター名、ゲーム進行データ(レベル、装備、クエストなど)は**端末内にのみ保存**されます。
|
||||||
|
|
||||||
|
## 2. データの保存と処理方法
|
||||||
|
|
||||||
|
- すべてのゲームデータは**ローカルストレージ(端末内)**にのみ保管されます。
|
||||||
|
- クラウドや外部サーバーへ自動送信されることはありません。
|
||||||
|
- アプリを削除すると、保存されたすべてのデータも削除されます。
|
||||||
|
|
||||||
|
## 3. 広告および第三者サービス
|
||||||
|
|
||||||
|
本アプリは**Google AdMob**広告ネットワークを使用しています。広告サービス提供のため、以下の情報が収集される場合があります:
|
||||||
|
|
||||||
|
- 広告識別子(Advertising ID)
|
||||||
|
- 端末情報(機種、OSバージョンなど)
|
||||||
|
- おおよその位置情報
|
||||||
|
- アプリ使用情報
|
||||||
|
|
||||||
|
これらの情報はGoogleのプライバシーポリシーに従って処理されます。
|
||||||
|
- Googleプライバシーポリシー:https://policies.google.com/privacy
|
||||||
|
|
||||||
|
## 4. アプリ内課金
|
||||||
|
|
||||||
|
本アプリは**広告削除**などの機能のためにアプリ内課金を提供しています。決済処理は各プラットフォーム(Google Play、Apple App Store)が直接行い、開発者は決済情報(カード番号、口座情報など)を収集・保存しません。
|
||||||
|
|
||||||
|
- Google Playプライバシーポリシー:https://policies.google.com/privacy
|
||||||
|
- Appleプライバシーポリシー:https://www.apple.com/legal/privacy/
|
||||||
|
|
||||||
|
## 5. 権限の使用
|
||||||
|
|
||||||
|
| 権限 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| ネットワークアクセス | 広告表示およびアプリ内課金処理 |
|
||||||
|
| ストレージアクセス | ゲームデータの保存 |
|
||||||
|
|
||||||
|
要求された権限は、上記の用途以外には使用されません。
|
||||||
|
|
||||||
|
## 6. 児童の個人情報
|
||||||
|
|
||||||
|
本アプリは一般ユーザーを対象として設計されており、**14歳未満の児童**から個人情報を収集することはありません。
|
||||||
|
|
||||||
|
## 7. 個人情報の保護
|
||||||
|
|
||||||
|
- すべてのゲームデータは端末内にのみ保存
|
||||||
|
- 外部サーバーへの個人情報送信なし
|
||||||
|
- 最小限の必要な権限のみを要求
|
||||||
|
|
||||||
|
## 8. プライバシーポリシーの変更
|
||||||
|
|
||||||
|
本プライバシーポリシーが変更される場合、アプリ内通知またはアプリストアの説明を通じてお知らせします。
|
||||||
|
|
||||||
|
## 9. お問い合わせ
|
||||||
|
|
||||||
|
- **メール:** naturebridgeai@gmail.com
|
||||||
|
- **担当者:** NatureBridgeAI アプリ開発チーム
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: January 30, 2026*
|
||||||
208
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# 아키텍처 문서
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── main.dart # 앱 진입점
|
||||||
|
├── data/ # 정적 데이터 (Config.dfm 추출)
|
||||||
|
│ ├── pq_config_data.dart
|
||||||
|
│ ├── race_data.dart
|
||||||
|
│ ├── class_data.dart
|
||||||
|
│ └── skill_data.dart
|
||||||
|
├── l10n/ # i18n 생성 파일
|
||||||
|
└── src/
|
||||||
|
├── app.dart # MaterialApp 설정
|
||||||
|
├── core/ # 도메인 레이어
|
||||||
|
│ ├── animation/ # ASCII 애니메이션
|
||||||
|
│ ├── audio/ # 오디오 서비스
|
||||||
|
│ ├── engine/ # 게임 로직
|
||||||
|
│ ├── model/ # 데이터 모델
|
||||||
|
│ ├── storage/ # 저장/로드
|
||||||
|
│ └── util/ # 유틸리티
|
||||||
|
├── features/ # 프레젠테이션 레이어
|
||||||
|
│ ├── arena/ # 아레나 화면
|
||||||
|
│ ├── front/ # 프론트 화면
|
||||||
|
│ ├── game/ # 게임 화면
|
||||||
|
│ ├── hall_of_fame/ # 명예의 전당
|
||||||
|
│ ├── new_character/ # 캐릭터 생성
|
||||||
|
│ └── settings/ # 설정
|
||||||
|
└── shared/ # 공통 위젯/스타일
|
||||||
|
```
|
||||||
|
|
||||||
|
## 레이어 구조 (Clean Architecture)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Presentation Layer │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ Screens │ │ Widgets │ │ Controllers │ │
|
||||||
|
│ │ (features/)│ │ (widgets/) │ │ (game_session_ │ │
|
||||||
|
│ │ │ │ │ │ controller.dart) │ │
|
||||||
|
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
|
||||||
|
└─────────┼────────────────┼───────────────────┼──────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Domain Layer │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ Models │ │ Services │ │ Managers │ │
|
||||||
|
│ │ (model/) │ │ (engine/) │ │ (game/managers/) │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ │ - GameState │ │ - Progress │ │ - Statistics │ │
|
||||||
|
│ │ - Equipment │ │ - Combat │ │ - SpeedBoost │ │
|
||||||
|
│ │ - Skills │ │ - Item │ │ - ReturnRewards │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ │ - Resurrection │ │
|
||||||
|
│ │ - HallOfFame │ │
|
||||||
|
│ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Data Layer │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ Static │ │ Storage │ │ External │ │
|
||||||
|
│ │ Data │ │ │ │ Services │ │
|
||||||
|
│ │ (data/) │ │ (storage/) │ │ │ │
|
||||||
|
│ │ │ │ │ │ - AdService │ │
|
||||||
|
│ │ - Config │ │ - SaveMgr │ │ - IAPService │ │
|
||||||
|
│ │ - Races │ │ - HallOfFame│ │ - AudioService │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## GameSessionController 매니저 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ GameSessionController │
|
||||||
|
│ (526 LOC) │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 핵심 책임: │ │
|
||||||
|
│ │ - 게임 루프 관리 (startNew, pause, resume) │ │
|
||||||
|
│ │ - 콜백 처리 (_onPlayerDied, _onGameComplete) │ │
|
||||||
|
│ │ - 상태 관리 (GameState, MonetizationState) │ │
|
||||||
|
│ └────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────────┼───────────────┐ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ Statistics │ │ SpeedBoost │ │ Return │ │
|
||||||
|
│ │ Manager │ │ Manager │ │ Rewards │ │
|
||||||
|
│ │ (140 LOC) │ │ (190 LOC) │ │ (180 LOC) │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │Resurrection │ │ HallOfFame │ │
|
||||||
|
│ │ Manager │ │ Manager │ │
|
||||||
|
│ │ (160 LOC) │ │ (130 LOC) │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 매니저별 책임
|
||||||
|
|
||||||
|
| 매니저 | 파일 | 책임 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `GameStatisticsManager` | `game_statistics_manager.dart` | 세션/누적 통계, 레벨업/골드/처치 추적 |
|
||||||
|
| `SpeedBoostManager` | `speed_boost_manager.dart` | 광고 배속, 버프 만료 체크 |
|
||||||
|
| `ReturnRewardsManager` | `return_rewards_manager.dart` | 복귀 보상 계산, 상자 보상 적용 |
|
||||||
|
| `ResurrectionManager` | `resurrection_manager.dart` | 일반/광고 부활, 자동부활 조건 |
|
||||||
|
| `HallOfFameManager` | `hall_of_fame_manager.dart` | 명예의 전당 등록, 테스트 캐릭터 |
|
||||||
|
|
||||||
|
## 게임 루프 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ startNew() │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ ProgressLoop │◄──────┐
|
||||||
|
│ (50ms tick) │ │
|
||||||
|
└────────┬─────────┘ │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌──────────────────┐ │
|
||||||
|
│ ProgressService │ │
|
||||||
|
│ .tick() │ │
|
||||||
|
└────────┬─────────┘ │
|
||||||
|
│ │
|
||||||
|
┌────┴────┐ │
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ │
|
||||||
|
┌───────┐ ┌───────┐ │
|
||||||
|
│ Task │ │ Quest │ │
|
||||||
|
│Process│ │/Plot │ │
|
||||||
|
└───┬───┘ └───┬───┘ │
|
||||||
|
│ │ │
|
||||||
|
└────┬────┘ │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌──────────────────┐ │
|
||||||
|
│ State Stream │───────┘
|
||||||
|
│ → UI Update │
|
||||||
|
└──────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ AutoSave │
|
||||||
|
│ (30초 간격) │
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
User Action
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ GamePlayScreen │
|
||||||
|
│ (UI Layer) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ GameSession │
|
||||||
|
│ Controller │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌────┴────┬────────┬────────┐
|
||||||
|
│ │ │ │
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐
|
||||||
|
│Progres│ │Stats │ │Resurre│ │HallOf │
|
||||||
|
│sLoop │ │Manager│ │ction │ │Fame │
|
||||||
|
└───┬───┘ └───────┘ │Manager│ │Manager│
|
||||||
|
│ └───────┘ └───────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ProgressService │
|
||||||
|
│ (Game Logic) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ GameState │
|
||||||
|
│ (Immutable) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ SaveManager │
|
||||||
|
│ (Persistence) │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 원본 PQ 알고리즘 매핑
|
||||||
|
|
||||||
|
| PQ 원본 (Delphi) | 포팅 위치 |
|
||||||
|
|-----------------|----------|
|
||||||
|
| `Main.pas:MonsterTask` | `progress_service.dart:_createMonsterTask()` |
|
||||||
|
| `Main.pas:StartTimer` | `progress_loop.dart:tick()` |
|
||||||
|
| `NewGuy.pas:RerollClick` | `character_roll_service.dart` |
|
||||||
|
| `Config.dfm` 데이터 | `data/pq_config_data.dart` |
|
||||||
1
docs/app-ads.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
google.com, pub-6691216385521068, DIRECT, f08c47fec0942fa0
|
||||||
395
docs/plan_monetization_system.md
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
# 수익화 시스템 설계
|
||||||
|
|
||||||
|
## 메타 정보
|
||||||
|
|
||||||
|
- **문서 버전**: 1.0
|
||||||
|
- **최종 수정**: 2026-01-16
|
||||||
|
- **상태**: 계획 단계
|
||||||
|
- **플랫폼**: Android/iOS (모바일 전용)
|
||||||
|
- **광고 SDK**: AdMob
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 리스트
|
||||||
|
|
||||||
|
### Phase 1: 데이터 구조 (선행 작업)
|
||||||
|
|
||||||
|
- [ ] `MonetizationState` 모델 생성
|
||||||
|
- [ ] `DeathInfo.lostItem: EquipmentItem?` 필드 추가
|
||||||
|
- [ ] `GameSave` v3 → v4 마이그레이션
|
||||||
|
- [ ] 장비 손실 확률 공식 변경: `20% + (level-1) * 8.89%`
|
||||||
|
- [ ] `ItemService.determineRarity()` 확률 수정: 34/40/20/5/1
|
||||||
|
|
||||||
|
### Phase 2: AdMob 연동
|
||||||
|
|
||||||
|
- [ ] `google_mobile_ads` 패키지 추가
|
||||||
|
- [ ] `AdService` 클래스 생성
|
||||||
|
- [ ] 리워드 광고 로드/표시/콜백 구현
|
||||||
|
- [ ] 인터스티셜 광고 로드/표시/콜백 구현
|
||||||
|
- [ ] 디버그 빌드 광고 ON/OFF 토글 구현
|
||||||
|
|
||||||
|
### Phase 3: IAP 연동
|
||||||
|
|
||||||
|
- [ ] `in_app_purchase` 패키지 추가
|
||||||
|
- [ ] 광고 제거 상품 등록 (Google Play / App Store)
|
||||||
|
- [ ] `IAPService` 클래스 생성
|
||||||
|
- [ ] 구매 처리 로직 구현
|
||||||
|
- [ ] 구매 복원 로직 구현
|
||||||
|
- [ ] 구매 상태 영구 저장
|
||||||
|
|
||||||
|
### Phase 4: 캐릭터 생성 광고
|
||||||
|
|
||||||
|
- [ ] 굴리기 횟수 상태 관리 (rollsRemaining)
|
||||||
|
- [ ] 굴리기 횟수 저장/로드 구현
|
||||||
|
- [ ] 되돌리기 히스토리 관리
|
||||||
|
- [ ] 되돌리기 횟수 상태 관리 (undoRemaining)
|
||||||
|
- [ ] 캐릭터 생성 UI 수정: 굴리기 버튼
|
||||||
|
- [ ] 캐릭터 생성 UI 수정: 되돌리기 버튼
|
||||||
|
|
||||||
|
### Phase 5: 부활 시스템
|
||||||
|
|
||||||
|
- [ ] 사망 시 `DeathInfo.lostItem` 저장 로직
|
||||||
|
- [ ] `reviveWithAdReward()` 함수 구현
|
||||||
|
- [ ] `reviveWithoutAd()` 함수 구현
|
||||||
|
- [ ] 자동부활 버프 상태 관리 (autoReviveEndMs)
|
||||||
|
- [ ] 자동부활 버프 중 사망 처리 로직
|
||||||
|
- [ ] 사망 화면 UI: 광고 부활 버튼
|
||||||
|
- [ ] 사망 화면 UI: 일반 부활 버튼
|
||||||
|
|
||||||
|
### Phase 6: 속도업
|
||||||
|
|
||||||
|
- [ ] 속도 상태 관리 (1x/2x/5x)
|
||||||
|
- [ ] 명예의 전당 캐릭터 존재 여부 확인 로직
|
||||||
|
- [ ] 5배속 버프 상태 관리 (speedBoostEndMs)
|
||||||
|
- [ ] 속도 UI: 버튼 표시 로직
|
||||||
|
- [ ] 속도 UI: 남은 시간 표시
|
||||||
|
|
||||||
|
### Phase 7: 복귀 보상
|
||||||
|
|
||||||
|
- [ ] `lastPlayTime` 저장/로드 구현
|
||||||
|
- [ ] 오프라인 시간 계산 로직
|
||||||
|
- [ ] 오프라인 진행 시뮬레이션 (1배/2배)
|
||||||
|
- [ ] 보물 상자 축적 로직
|
||||||
|
- [ ] 보물 상자 내용물 생성 로직
|
||||||
|
- [ ] 행운의 부적 버프 발동 로직
|
||||||
|
- [ ] 복귀 보상 UI: 환영 다이얼로그
|
||||||
|
- [ ] 복귀 보상 UI: 상자 오픈
|
||||||
|
|
||||||
|
### Phase 8: 디버그 기능
|
||||||
|
|
||||||
|
- [ ] 메인 메뉴: 디버그 옵션 섹션 (kDebugMode)
|
||||||
|
- [ ] 스타트 화면: 디버그 옵션 섹션
|
||||||
|
- [ ] 광고 ON/OFF 토글
|
||||||
|
- [ ] IAP 구매 시뮬레이션 토글
|
||||||
|
- [ ] 오프라인 시간 시뮬레이션
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 스펙 요약
|
||||||
|
|
||||||
|
### 광고 유형
|
||||||
|
|
||||||
|
| ID | 유형 | 길이 | 사용처 |
|
||||||
|
| -- | ---- | ---- | ------ |
|
||||||
|
| AD_REWARD_REVIVE | 리워드 | 30초 | 부활 |
|
||||||
|
| AD_REWARD_UNDO | 리워드 | 30초 | 캐릭터 생성 되돌리기 |
|
||||||
|
| AD_INTERSTITIAL_ROLL | 인터스티셜 | 6초 | 굴리기 횟수 충전 |
|
||||||
|
| AD_INTERSTITIAL_SPEED | 인터스티셜 | 6초 | 게임 속도업 |
|
||||||
|
|
||||||
|
### IAP 상품
|
||||||
|
|
||||||
|
| ID | 가격 | 유형 |
|
||||||
|
| -- | ---- | ---- |
|
||||||
|
| remove_ads | $9.99 | 비소모성 (1회 구매) |
|
||||||
|
|
||||||
|
### 무료 vs 구매 유저 비교
|
||||||
|
|
||||||
|
| 기능 | 무료 유저 | 구매 유저 |
|
||||||
|
| ---- | --------- | --------- |
|
||||||
|
| 광고 | 표시 | 제거 (버튼 클릭 시 바로 활성화) |
|
||||||
|
| 복귀 상자 최대 | 5개 | 10개 |
|
||||||
|
| 오프라인 진행 속도 | 1배 | 2배 |
|
||||||
|
| 행운 버프 발동 | 1시간당 5분 | 30분당 5분 |
|
||||||
|
| 캐릭터 되돌리기 | 1회 (광고) | 3회 (무료) |
|
||||||
|
| 굴리기 충전 | 광고 필요 | 무제한 |
|
||||||
|
| 속도업 | 광고 필요 | 무제한 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 상세 스펙
|
||||||
|
|
||||||
|
### 1. 캐릭터 생성 - 굴리기
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
rollsRemaining:
|
||||||
|
default: 5
|
||||||
|
min: 0
|
||||||
|
max: 5
|
||||||
|
recharge: +5 (인터스티셜 광고 시청 후)
|
||||||
|
persistence: 저장됨 (0회로 종료 시 재시작해도 0회)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 캐릭터 생성 - 되돌리기
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
undoRemaining:
|
||||||
|
free_user: 1
|
||||||
|
paid_user: 3
|
||||||
|
ad_required:
|
||||||
|
free_user: true (30초 리워드)
|
||||||
|
paid_user: false
|
||||||
|
range: 1단계 전만
|
||||||
|
reset: 새로 굴리기 시작 시 초기화
|
||||||
|
constraint: min(undoRemaining, rollHistory.length)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 장비 손실 확률
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
formula: 20 + (level - 1) * 80 / 9
|
||||||
|
level_1: 20%
|
||||||
|
level_5: 56%
|
||||||
|
level_10_plus: 100%
|
||||||
|
code: |
|
||||||
|
int calculateEquipmentLossChance(int level) {
|
||||||
|
if (level >= 10) return 100;
|
||||||
|
return 20 + ((level - 1) * 80 ~/ 9);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 부활 시스템
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
normal_revive:
|
||||||
|
ad: false
|
||||||
|
hp_recovery: 50%
|
||||||
|
equipment_loss: confirmed
|
||||||
|
sacrifice: required
|
||||||
|
|
||||||
|
ad_revive:
|
||||||
|
ad: true (30초 리워드)
|
||||||
|
hp_recovery: 100%
|
||||||
|
equipment_loss: cancelled (lostItem 복구)
|
||||||
|
sacrifice: none
|
||||||
|
auto_revive_buff:
|
||||||
|
duration: 600000ms (10분)
|
||||||
|
effect: 버프 중 사망 시 자동부활 + 장비 손실 없음
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 게임 속도
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
speed_levels:
|
||||||
|
- 1x: 기본
|
||||||
|
- 2x: 명예의 전당 캐릭터 1명 이상 시 해금
|
||||||
|
- 5x: 광고 시청 (5분간) 또는 IAP 구매 시 무제한
|
||||||
|
|
||||||
|
speed_boost:
|
||||||
|
duration: 300000ms (5분)
|
||||||
|
ad: 인터스티셜 6초
|
||||||
|
paid_user: 광고 없이 무제한
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 복귀 보상
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
offline_progress:
|
||||||
|
free_user: 1x
|
||||||
|
paid_user: 2x
|
||||||
|
|
||||||
|
treasure_chest:
|
||||||
|
rate: 1개/시간
|
||||||
|
max_free: 5개
|
||||||
|
max_paid: 10개
|
||||||
|
contents:
|
||||||
|
equipment: 50%
|
||||||
|
gold: 30%
|
||||||
|
potion: 15%
|
||||||
|
bonus_equipment: 5%
|
||||||
|
|
||||||
|
lucky_charm_buff:
|
||||||
|
free_user: 5분/오프라인1시간
|
||||||
|
paid_user: 5분/오프라인30분
|
||||||
|
max_duration: 30분
|
||||||
|
effect:
|
||||||
|
common: 34% → 28%
|
||||||
|
uncommon: 40% → 40%
|
||||||
|
rare: 20% → 20%
|
||||||
|
epic: 5% → 10%
|
||||||
|
legendary: 1% → 2%
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 아이템 희귀도 (목표)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
rarity_distribution:
|
||||||
|
common: 34%
|
||||||
|
uncommon: 40%
|
||||||
|
rare: 20%
|
||||||
|
epic: 5%
|
||||||
|
legendary: 1%
|
||||||
|
note: 현재 코드와 다름. ItemService.determineRarity() 수정 필요
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 모델
|
||||||
|
|
||||||
|
### MonetizationState
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class MonetizationState {
|
||||||
|
final bool adRemovalPurchased; // IAP 구매 여부
|
||||||
|
final int rollsRemaining; // 굴리기 남은 횟수 (0-5)
|
||||||
|
final int undoRemaining; // 되돌리기 남은 횟수
|
||||||
|
final List<Stats>? rollHistory; // 되돌리기용 히스토리
|
||||||
|
final int? autoReviveEndMs; // 자동부활 버프 종료 시점
|
||||||
|
final int? speedBoostEndMs; // 5배속 종료 시점
|
||||||
|
final DateTime? lastPlayTime; // 마지막 플레이 시각
|
||||||
|
final int pendingChests; // 미개봉 상자 개수
|
||||||
|
final int? luckyCharmEndMs; // 행운 버프 종료 시점
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DeathInfo 확장
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class DeathInfo {
|
||||||
|
// 기존 필드
|
||||||
|
final String? lostItemName;
|
||||||
|
final EquipmentSlot? lostItemSlot;
|
||||||
|
final ItemRarity? lostItemRarity;
|
||||||
|
|
||||||
|
// 신규 필드
|
||||||
|
final EquipmentItem? lostItem; // 복구용 전체 장비 정보
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GameSave 버전
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
current_version: 3
|
||||||
|
next_version: 4
|
||||||
|
migration:
|
||||||
|
- add: MonetizationState monetization
|
||||||
|
- add: DeathInfo.lostItem
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 엣지 케이스
|
||||||
|
|
||||||
|
| 케이스 | 조건 | 처리 |
|
||||||
|
| ------ | ---- | ---- |
|
||||||
|
| 광고 로드 실패 | 네트워크 오류 | 재시도 버튼 표시 |
|
||||||
|
| 오프라인 상태 | 네트워크 없음 | 광고 버튼 비활성화 |
|
||||||
|
| 광고 중 앱 종료 | 광고 미완료 | 보상 미지급 |
|
||||||
|
| IAP 복원 | 앱 재설치 | 구글/애플 구매기록 확인 |
|
||||||
|
| 자동부활 중 종료 | 버프 활성 중 | 남은 시간 저장 |
|
||||||
|
| 시간 조작 | lastPlayTime > now | 복귀 보상 없음 |
|
||||||
|
| 굴리기 0회 종료 | rollsRemaining == 0 | 0회 유지 |
|
||||||
|
| 되돌리기 초과 | undoRemaining > historyLength | min 적용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 디버그 옵션 (kDebugMode 전용)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
debug_options:
|
||||||
|
ad_enabled:
|
||||||
|
type: bool
|
||||||
|
default: true
|
||||||
|
off_behavior: 광고 버튼 클릭 시 바로 보상
|
||||||
|
on_behavior: 실제 광고 재생
|
||||||
|
|
||||||
|
iap_simulated:
|
||||||
|
type: bool
|
||||||
|
default: false
|
||||||
|
off_behavior: 무료 유저로 동작
|
||||||
|
on_behavior: 구매 유저로 동작
|
||||||
|
|
||||||
|
offline_hours:
|
||||||
|
type: int
|
||||||
|
options: [0, 1, 5, 10]
|
||||||
|
default: 0
|
||||||
|
purpose: 복귀 보상 테스트
|
||||||
|
|
||||||
|
locations:
|
||||||
|
- 메인 메뉴 (광고 제거 버튼 아래)
|
||||||
|
- 스타트 화면
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI 요약
|
||||||
|
|
||||||
|
### 메인 메뉴
|
||||||
|
|
||||||
|
```
|
||||||
|
[ 새 게임 ]
|
||||||
|
[ 불러오기 ]
|
||||||
|
[ 설정 ]
|
||||||
|
[ 명예의 전당 ]
|
||||||
|
────────────
|
||||||
|
[ 광고 제거 - $9.99 ] ← 구매 후 비활성화
|
||||||
|
── Debug Only ──
|
||||||
|
[ 광고: ON/OFF ]
|
||||||
|
[ IAP: 미구매/구매 ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 캐릭터 생성
|
||||||
|
|
||||||
|
```
|
||||||
|
STR: 14 CON: 12 DEX: 10
|
||||||
|
INT: 16 WIS: 8 CHA: 11
|
||||||
|
Total: 71
|
||||||
|
|
||||||
|
[ 굴리기 (N/5) ] ← 0회 시 🎬 표시
|
||||||
|
[ 🎬 이전으로 (N/M) ] ← 무료:1회, 구매:3회
|
||||||
|
```
|
||||||
|
|
||||||
|
### 게임 화면 - 속도
|
||||||
|
|
||||||
|
```
|
||||||
|
명예의 전당 없음: [ 1x ] [ 🎬 5x ]
|
||||||
|
명예의 전당 있음: [ 1x ] [ 2x ] [ 🎬 5x ]
|
||||||
|
5배속 중: [ 5x ⏱️ 4:32 ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 사망 화면
|
||||||
|
|
||||||
|
```
|
||||||
|
☠️ You Died
|
||||||
|
Killed by: {monster}
|
||||||
|
Lost: {item}
|
||||||
|
|
||||||
|
[ 🎬 광고 부활 (30초) ]
|
||||||
|
- 장비 복구
|
||||||
|
- 제물 없음
|
||||||
|
- HP 100%
|
||||||
|
- 10분 자동부활
|
||||||
|
|
||||||
|
[ 일반 부활 ]
|
||||||
|
- 장비 손실
|
||||||
|
- 제물 소모
|
||||||
|
- HP 50%
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
### 관련 파일
|
||||||
|
|
||||||
|
- `lib/src/core/model/game_state.dart`: GameState 모델
|
||||||
|
- `lib/src/core/engine/progress_service.dart`: 사망 처리 로직
|
||||||
|
- `lib/src/core/storage/`: 저장/로드 시스템
|
||||||
|
- `lib/src/core/engine/item_service.dart`: 아이템 희귀도 결정
|
||||||
|
|
||||||
|
### 의존성 패키지
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dependencies:
|
||||||
|
google_mobile_ads: ^5.0.0 # AdMob
|
||||||
|
in_app_purchase: ^3.1.0 # IAP
|
||||||
|
```
|
||||||
@@ -361,14 +361,16 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = 82SY27V867;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie;
|
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.asciineverdie;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -384,7 +386,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.asciineverdie.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -401,7 +403,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.asciineverdie.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -416,7 +418,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.asciineverdie.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -427,7 +429,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
@@ -484,7 +486,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
@@ -540,14 +542,16 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = 82SY27V867;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie;
|
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.asciineverdie;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -562,14 +566,16 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = 82SY27V867;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie;
|
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.asciineverdie;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@@ -1,122 +1 @@
|
|||||||
{
|
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "83.5x83.5",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "1024x1024",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"filename" : "Icon-App-1024x1024@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 330 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 765 B |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 482 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 765 B |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 8.5 KiB |
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Askiineverdie</string>
|
<string>ASCII Never Die</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>askiineverdie</string>
|
<string>ASCII Never Die</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@@ -45,5 +45,28 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:askiineverdie/src/core/model/class_traits.dart';
|
import 'package:asciineverdie/src/core/model/class_traits.dart';
|
||||||
import 'package:askiineverdie/src/core/model/race_traits.dart';
|
import 'package:asciineverdie/src/core/model/race_traits.dart';
|
||||||
|
|
||||||
/// 클래스 데이터 정의 (class data)
|
/// 클래스 데이터 정의 (class data)
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -42,14 +42,14 @@ const Map<String, String> raceTranslationsJa = {
|
|||||||
'Kernel Giant': 'カーネルジャイアント',
|
'Kernel Giant': 'カーネルジャイアント',
|
||||||
'Thread Spirit': 'スレッドスピリット',
|
'Thread Spirit': 'スレッドスピリット',
|
||||||
'Coredump Undead': 'コアダンプアンデッド',
|
'Coredump Undead': 'コアダンプアンデッド',
|
||||||
'Flag Knight': 'フラグナイト',
|
'Flag Golem': 'フラグゴーレム',
|
||||||
'Loop Wizard': 'ループウィザード',
|
'Loop Djinn': 'ループジン',
|
||||||
'Recursive Sage': 'リカーシブセイジ',
|
'Recursive Sylvan': 'リカーシブシルヴァン',
|
||||||
'Iterator Rogue': 'イテレーターローグ',
|
'Iterator Shade': 'イテレーターシェイド',
|
||||||
'Callback Priest': 'コールバックプリースト',
|
'Callback Seraph': 'コールバックセラフ',
|
||||||
'Lambda Druid': 'ラムダドルイド',
|
'Lambda Dryad': 'ラムダドライアド',
|
||||||
'Protocol Paladin': 'プロトコルパラディン',
|
'Protocol Valkyrie': 'プロトコルヴァルキリー',
|
||||||
'Index Ranger': 'インデックスレンジャー',
|
'Index Feline': 'インデックスフェライン',
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 職業名日本語翻訳
|
/// 職業名日本語翻訳
|
||||||
@@ -510,6 +510,17 @@ const Map<String, String> weaponTranslationsJa = {
|
|||||||
'Dyson Sphere Core': 'ダイソン球コア',
|
'Dyson Sphere Core': 'ダイソン球コア',
|
||||||
'Black Hole Computer': 'ブラックホールコンピューター',
|
'Black Hole Computer': 'ブラックホールコンピューター',
|
||||||
'Universe Simulator': '宇宙シミュレーター',
|
'Universe Simulator': '宇宙シミュレーター',
|
||||||
|
'Dimensional Gateway': '次元の門',
|
||||||
|
'Time Loop Device': 'タイムループデバイス',
|
||||||
|
'Reality Compiler': 'リアリティコンパイラー',
|
||||||
|
'Multiverse Bridge': 'マルチバースブリッジ',
|
||||||
|
'Cosmic Debugger': 'コズミックデバッガー',
|
||||||
|
'Entropy Reverser': 'エントロピーリバーサー',
|
||||||
|
'Big Bang Trigger': 'ビッグバントリガー',
|
||||||
|
'Heat Death Preventer': '熱的死防止装置',
|
||||||
|
'Infinity Engine': '無限エンジン',
|
||||||
|
'Omniscience Module': '全知モジュール',
|
||||||
|
'God Mode Activator': '神モードアクティベーター',
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 鎧名日本語翻訳
|
/// 鎧名日本語翻訳
|
||||||
|
|||||||
@@ -42,14 +42,14 @@ const Map<String, String> raceTranslationsKo = {
|
|||||||
'Kernel Giant': '커널 거인',
|
'Kernel Giant': '커널 거인',
|
||||||
'Thread Spirit': '스레드 정령',
|
'Thread Spirit': '스레드 정령',
|
||||||
'Coredump Undead': '코어덤프 언데드',
|
'Coredump Undead': '코어덤프 언데드',
|
||||||
'Flag Knight': '플래그 기사',
|
'Flag Golem': '플래그 골렘',
|
||||||
'Loop Wizard': '루프 마법사',
|
'Loop Djinn': '루프 진',
|
||||||
'Recursive Sage': '재귀 현자',
|
'Recursive Sylvan': '재귀 실반',
|
||||||
'Iterator Rogue': '이터레이터 도적',
|
'Iterator Shade': '이터레이터 셰이드',
|
||||||
'Callback Priest': '콜백 사제',
|
'Callback Seraph': '콜백 세라핌',
|
||||||
'Lambda Druid': '람다 드루이드',
|
'Lambda Dryad': '람다 드라이어드',
|
||||||
'Protocol Paladin': '프로토콜 성기사',
|
'Protocol Valkyrie': '프로토콜 발키리',
|
||||||
'Index Ranger': '인덱스 레인저',
|
'Index Feline': '인덱스 펠라인',
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 직업 이름 한국어 번역
|
/// 직업 이름 한국어 번역
|
||||||
@@ -510,6 +510,17 @@ const Map<String, String> weaponTranslationsKo = {
|
|||||||
'Dyson Sphere Core': '다이슨 구 코어',
|
'Dyson Sphere Core': '다이슨 구 코어',
|
||||||
'Black Hole Computer': '블랙홀 컴퓨터',
|
'Black Hole Computer': '블랙홀 컴퓨터',
|
||||||
'Universe Simulator': '우주 시뮬레이터',
|
'Universe Simulator': '우주 시뮬레이터',
|
||||||
|
'Dimensional Gateway': '차원의 관문',
|
||||||
|
'Time Loop Device': '시간 루프 장치',
|
||||||
|
'Reality Compiler': '현실 컴파일러',
|
||||||
|
'Multiverse Bridge': '다중 우주 다리',
|
||||||
|
'Cosmic Debugger': '우주 디버거',
|
||||||
|
'Entropy Reverser': '엔트로피 역전기',
|
||||||
|
'Big Bang Trigger': '빅뱅 트리거',
|
||||||
|
'Heat Death Preventer': '열 죽음 방지기',
|
||||||
|
'Infinity Engine': '무한 엔진',
|
||||||
|
'Omniscience Module': '전지 모듈',
|
||||||
|
'God Mode Activator': '신 모드 활성기',
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 갑옷 이름 한국어 번역
|
/// 갑옷 이름 한국어 번역
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import 'package:askiineverdie/src/core/model/potion.dart';
|
import 'package:asciineverdie/src/core/model/potion.dart';
|
||||||
|
|
||||||
/// 게임 내 물약 정의
|
/// 게임 내 물약 정의
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -740,14 +740,14 @@ const Map<String, List<String>> pqConfigData = {
|
|||||||
'Kernel Giant|STR,HP Max',
|
'Kernel Giant|STR,HP Max',
|
||||||
'Thread Spirit|MP Max',
|
'Thread Spirit|MP Max',
|
||||||
'Coredump Undead|CON',
|
'Coredump Undead|CON',
|
||||||
'Flag Knight|CHA,STR',
|
'Flag Golem|CHA,STR',
|
||||||
'Loop Wizard|INT,MP Max',
|
'Loop Djinn|INT,MP Max',
|
||||||
'Recursive Sage|WIS,INT',
|
'Recursive Sylvan|WIS,INT',
|
||||||
'Iterator Rogue|DEX',
|
'Iterator Shade|DEX',
|
||||||
'Callback Priest|WIS,CHA',
|
'Callback Seraph|WIS,CHA',
|
||||||
'Lambda Druid|INT,WIS',
|
'Lambda Dryad|INT,WIS',
|
||||||
'Protocol Paladin|STR,CHA',
|
'Protocol Valkyrie|STR,CHA',
|
||||||
'Index Ranger|DEX,CON',
|
'Index Feline|DEX,CON',
|
||||||
],
|
],
|
||||||
'Shields': [
|
'Shields': [
|
||||||
'CAPTCHA|0',
|
'CAPTCHA|0',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import 'package:askiineverdie/src/core/model/race_traits.dart';
|
import 'package:asciineverdie/src/core/model/race_traits.dart';
|
||||||
|
|
||||||
/// 종족 데이터 정의 (race data)
|
/// 종족 데이터 정의 (race data)
|
||||||
///
|
///
|
||||||
@@ -71,11 +71,11 @@ class RaceData {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Recursive Sage: 순수 마법형 (스탯 합계: 0)
|
/// Recursive Sylvan: 순수 마법형 (스탯 합계: 0)
|
||||||
/// WIS/INT +2, STR -2, DEX -1, CHA +1
|
/// WIS/INT +2, STR -2, DEX -1, CHA +1
|
||||||
static const recursiveSage = RaceTraits(
|
static const recursiveSylvan = RaceTraits(
|
||||||
raceId: 'recursive_sage',
|
raceId: 'recursive_sylvan',
|
||||||
name: 'Recursive Sage',
|
name: 'Recursive Sylvan',
|
||||||
statModifiers: {
|
statModifiers: {
|
||||||
StatType.wis: 2,
|
StatType.wis: 2,
|
||||||
StatType.intelligence: 2,
|
StatType.intelligence: 2,
|
||||||
@@ -92,11 +92,11 @@ class RaceData {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Callback Priest: 지원형 (스탯 합계: 0)
|
/// Callback Seraph: 지원형 (스탯 합계: 0)
|
||||||
/// WIS +2, CHA +1, STR -1, DEX -1, CON -1
|
/// WIS +2, CHA +1, STR -1, DEX -1, CON -1
|
||||||
static const callbackPriest = RaceTraits(
|
static const callbackSeraph = RaceTraits(
|
||||||
raceId: 'callback_priest',
|
raceId: 'callback_seraph',
|
||||||
name: 'Callback Priest',
|
name: 'Callback Seraph',
|
||||||
statModifiers: {
|
statModifiers: {
|
||||||
StatType.wis: 2,
|
StatType.wis: 2,
|
||||||
StatType.cha: 1,
|
StatType.cha: 1,
|
||||||
@@ -151,9 +151,9 @@ class RaceData {
|
|||||||
},
|
},
|
||||||
passives: [
|
passives: [
|
||||||
PassiveAbility(
|
PassiveAbility(
|
||||||
type: PassiveType.deathEquipmentPreserve,
|
type: PassiveType.defenseBonus,
|
||||||
value: 1.0,
|
value: 0.10,
|
||||||
description: '사망 시 장비 1개 유지',
|
description: '방어력 +10%',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -202,11 +202,11 @@ class RaceData {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Iterator Rogue: 암살형 (스탯 합계: 0)
|
/// Iterator Shade: 암살형 (스탯 합계: 0)
|
||||||
/// DEX +3, STR +1, CON -2, WIS -1, CHA -1
|
/// DEX +3, STR +1, CON -2, WIS -1, CHA -1
|
||||||
static const iteratorRogue = RaceTraits(
|
static const iteratorShade = RaceTraits(
|
||||||
raceId: 'iterator_rogue',
|
raceId: 'iterator_shade',
|
||||||
name: 'Iterator Rogue',
|
name: 'Iterator Shade',
|
||||||
statModifiers: {
|
statModifiers: {
|
||||||
StatType.dex: 3,
|
StatType.dex: 3,
|
||||||
StatType.str: 1,
|
StatType.str: 1,
|
||||||
@@ -247,11 +247,11 @@ class RaceData {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Flag Knight: 전사형 (스탯 합계: 0)
|
/// Flag Golem: 전사형 (스탯 합계: 0)
|
||||||
/// STR +2, CHA +1, INT -2, WIS -1
|
/// STR +2, CHA +1, INT -2, WIS -1
|
||||||
static const flagKnight = RaceTraits(
|
static const flagGolem = RaceTraits(
|
||||||
raceId: 'flag_knight',
|
raceId: 'flag_golem',
|
||||||
name: 'Flag Knight',
|
name: 'Flag Golem',
|
||||||
statModifiers: {
|
statModifiers: {
|
||||||
StatType.str: 2,
|
StatType.str: 2,
|
||||||
StatType.cha: 1,
|
StatType.cha: 1,
|
||||||
@@ -267,11 +267,11 @@ class RaceData {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Protocol Paladin: 수호자형 (스탯 합계: 0)
|
/// Protocol Valkyrie: 수호자형 (스탯 합계: 0)
|
||||||
/// STR +2, CON +1, CHA +1, DEX -2, INT -2
|
/// STR +2, CON +1, CHA +1, DEX -2, INT -2
|
||||||
static const protocolPaladin = RaceTraits(
|
static const protocolValkyrie = RaceTraits(
|
||||||
raceId: 'protocol_paladin',
|
raceId: 'protocol_valkyrie',
|
||||||
name: 'Protocol Paladin',
|
name: 'Protocol Valkyrie',
|
||||||
statModifiers: {
|
statModifiers: {
|
||||||
StatType.str: 2,
|
StatType.str: 2,
|
||||||
StatType.con: 1,
|
StatType.con: 1,
|
||||||
@@ -332,11 +332,11 @@ class RaceData {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Index Ranger: 정찰형 (스탯 합계: 0)
|
/// Index Feline: 정찰형 (스탯 합계: 0)
|
||||||
/// DEX +2, CON +1, INT -1, CHA -2
|
/// DEX +2, CON +1, INT -1, CHA -2
|
||||||
static const indexRanger = RaceTraits(
|
static const indexFeline = RaceTraits(
|
||||||
raceId: 'index_ranger',
|
raceId: 'index_feline',
|
||||||
name: 'Index Ranger',
|
name: 'Index Feline',
|
||||||
statModifiers: {
|
statModifiers: {
|
||||||
StatType.dex: 2,
|
StatType.dex: 2,
|
||||||
StatType.con: 1,
|
StatType.con: 1,
|
||||||
@@ -415,11 +415,11 @@ class RaceData {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Loop Wizard: 순환 마법형 (스탯 합계: 0)
|
/// Loop Djinn: 순환 마법형 (스탯 합계: 0)
|
||||||
/// INT +2, WIS +1, STR -1, CON -1, DEX -1
|
/// INT +2, WIS +1, STR -1, CON -1, DEX -1
|
||||||
static const loopWizard = RaceTraits(
|
static const loopDjinn = RaceTraits(
|
||||||
raceId: 'loop_wizard',
|
raceId: 'loop_djinn',
|
||||||
name: 'Loop Wizard',
|
name: 'Loop Djinn',
|
||||||
statModifiers: {
|
statModifiers: {
|
||||||
StatType.intelligence: 2,
|
StatType.intelligence: 2,
|
||||||
StatType.wis: 1,
|
StatType.wis: 1,
|
||||||
@@ -441,11 +441,11 @@ class RaceData {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Lambda Druid: 자연 마법형 (스탯 합계: 0)
|
/// Lambda Dryad: 자연 마법형 (스탯 합계: 0)
|
||||||
/// INT +2, WIS +2, STR -2, CON -2
|
/// INT +2, WIS +2, STR -2, CON -2
|
||||||
static const lambdaDruid = RaceTraits(
|
static const lambdaDryad = RaceTraits(
|
||||||
raceId: 'lambda_druid',
|
raceId: 'lambda_dryad',
|
||||||
name: 'Lambda Druid',
|
name: 'Lambda Dryad',
|
||||||
statModifiers: {
|
statModifiers: {
|
||||||
StatType.intelligence: 2,
|
StatType.intelligence: 2,
|
||||||
StatType.wis: 2,
|
StatType.wis: 2,
|
||||||
@@ -473,29 +473,29 @@ class RaceData {
|
|||||||
kernelGiant,
|
kernelGiant,
|
||||||
// 지혜형
|
// 지혜형
|
||||||
nullElf,
|
nullElf,
|
||||||
recursiveSage,
|
recursiveSylvan,
|
||||||
callbackPriest,
|
callbackSeraph,
|
||||||
// 체력형
|
// 체력형
|
||||||
bufferDwarf,
|
bufferDwarf,
|
||||||
coredumpUndead,
|
coredumpUndead,
|
||||||
// 민첩형
|
// 민첩형
|
||||||
bitHalfling,
|
bitHalfling,
|
||||||
cacheImp,
|
cacheImp,
|
||||||
iteratorRogue,
|
iteratorShade,
|
||||||
// 힘형
|
// 힘형
|
||||||
arrayOrc,
|
arrayOrc,
|
||||||
flagKnight,
|
flagGolem,
|
||||||
protocolPaladin,
|
protocolValkyrie,
|
||||||
// 복합형
|
// 복합형
|
||||||
stackGoblin,
|
stackGoblin,
|
||||||
heapTroll,
|
heapTroll,
|
||||||
indexRanger,
|
indexFeline,
|
||||||
// 마법형
|
// 마법형
|
||||||
pointerFairy,
|
pointerFairy,
|
||||||
registerGnome,
|
registerGnome,
|
||||||
threadSpirit,
|
threadSpirit,
|
||||||
loopWizard,
|
loopDjinn,
|
||||||
lambdaDruid,
|
lambdaDryad,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// ID로 종족 찾기
|
/// ID로 종족 찾기
|
||||||
|
|||||||
@@ -227,8 +227,8 @@
|
|||||||
"total": "Total",
|
"total": "Total",
|
||||||
"@total": { "description": "Total label for stats" },
|
"@total": { "description": "Total label for stats" },
|
||||||
|
|
||||||
"unroll": "Unroll",
|
"unroll": "Undo",
|
||||||
"@unroll": { "description": "Unroll button" },
|
"@unroll": { "description": "Undo button for stat reroll" },
|
||||||
|
|
||||||
"roll": "Roll",
|
"roll": "Roll",
|
||||||
"@roll": { "description": "Roll button" },
|
"@roll": { "description": "Roll button" },
|
||||||
@@ -251,5 +251,460 @@
|
|||||||
"@newCharacterTitle": { "description": "New character screen title" },
|
"@newCharacterTitle": { "description": "New character screen title" },
|
||||||
|
|
||||||
"soldButton": "Sold!",
|
"soldButton": "Sold!",
|
||||||
"@soldButton": { "description": "Confirm character creation button" }
|
"@soldButton": { "description": "Confirm character creation button" },
|
||||||
|
|
||||||
|
"endingCongratulations": "★ CONGRATULATIONS ★",
|
||||||
|
"@endingCongratulations": { "description": "Victory overlay congratulations" },
|
||||||
|
|
||||||
|
"endingGameComplete": "You have completed the game!",
|
||||||
|
"@endingGameComplete": { "description": "Game completion message" },
|
||||||
|
|
||||||
|
"endingTheHero": "THE HERO",
|
||||||
|
"@endingTheHero": { "description": "Hero section title" },
|
||||||
|
|
||||||
|
"endingLevelFormat": "Level {level}",
|
||||||
|
"@endingLevelFormat": {
|
||||||
|
"description": "Level display format",
|
||||||
|
"placeholders": {
|
||||||
|
"level": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"endingJourneyStats": "JOURNEY STATISTICS",
|
||||||
|
"@endingJourneyStats": { "description": "Journey statistics section title" },
|
||||||
|
|
||||||
|
"endingMonstersSlain": "Monsters Slain",
|
||||||
|
"@endingMonstersSlain": { "description": "Monsters killed stat label" },
|
||||||
|
|
||||||
|
"endingQuestsCompleted": "Quests Completed",
|
||||||
|
"@endingQuestsCompleted": { "description": "Quests completed stat label" },
|
||||||
|
|
||||||
|
"endingPlayTime": "Play Time",
|
||||||
|
"@endingPlayTime": { "description": "Play time stat label" },
|
||||||
|
|
||||||
|
"endingFinalStats": "FINAL STATS",
|
||||||
|
"@endingFinalStats": { "description": "Final stats section title" },
|
||||||
|
|
||||||
|
"endingCredits": "CREDITS",
|
||||||
|
"@endingCredits": { "description": "Credits section title" },
|
||||||
|
|
||||||
|
"endingThankYou": "Thank you for playing!",
|
||||||
|
"@endingThankYou": { "description": "Thank you message" },
|
||||||
|
|
||||||
|
"endingLegendLivesOn": "Your legend lives on...",
|
||||||
|
"@endingLegendLivesOn": { "description": "Legend message" },
|
||||||
|
|
||||||
|
"endingHallOfFameLine1": "Your heroic deeds will be",
|
||||||
|
"@endingHallOfFameLine1": { "description": "Hall of fame message line 1" },
|
||||||
|
|
||||||
|
"endingHallOfFameLine2": "remembered in the Hall of Fame",
|
||||||
|
"@endingHallOfFameLine2": { "description": "Hall of fame message line 2" },
|
||||||
|
|
||||||
|
"endingHallOfFameButton": "HALL OF FAME",
|
||||||
|
"@endingHallOfFameButton": { "description": "Hall of fame button" },
|
||||||
|
|
||||||
|
"endingSkip": "SKIP",
|
||||||
|
"@endingSkip": { "description": "Skip button" },
|
||||||
|
|
||||||
|
"endingTapToSkip": "TAP TO SKIP",
|
||||||
|
"@endingTapToSkip": { "description": "Tap to skip hint" },
|
||||||
|
|
||||||
|
"endingHoldToSpeedUp": "HOLD TO SPEED UP",
|
||||||
|
"@endingHoldToSpeedUp": { "description": "Hold to speed up scrolling hint" },
|
||||||
|
|
||||||
|
"menuTitle": "MENU",
|
||||||
|
"@menuTitle": { "description": "Menu panel title" },
|
||||||
|
|
||||||
|
"optionsTitle": "OPTIONS",
|
||||||
|
"@optionsTitle": { "description": "Options menu title" },
|
||||||
|
|
||||||
|
"soundTitle": "SOUND",
|
||||||
|
"@soundTitle": { "description": "Sound dialog title" },
|
||||||
|
|
||||||
|
"controlSection": "CONTROL",
|
||||||
|
"@controlSection": { "description": "Control section title" },
|
||||||
|
|
||||||
|
"infoSection": "INFO",
|
||||||
|
"@infoSection": { "description": "Info section title" },
|
||||||
|
|
||||||
|
"settingsSection": "SETTINGS",
|
||||||
|
"@settingsSection": { "description": "Settings section title" },
|
||||||
|
|
||||||
|
"saveExitSection": "SAVE / EXIT",
|
||||||
|
"@saveExitSection": { "description": "Save/Exit section title" },
|
||||||
|
|
||||||
|
"ok": "OK",
|
||||||
|
"@ok": { "description": "OK button" },
|
||||||
|
|
||||||
|
"rechargeButton": "RECHARGE",
|
||||||
|
"@rechargeButton": { "description": "Recharge button" },
|
||||||
|
|
||||||
|
"createButton": "CREATE",
|
||||||
|
"@createButton": { "description": "Create button" },
|
||||||
|
|
||||||
|
"previewTitle": "PREVIEW",
|
||||||
|
"@previewTitle": { "description": "Preview panel title" },
|
||||||
|
|
||||||
|
"nameTitle": "NAME",
|
||||||
|
"@nameTitle": { "description": "Name panel title" },
|
||||||
|
|
||||||
|
"statsTitle": "STATS",
|
||||||
|
"@statsTitle": { "description": "Stats panel title" },
|
||||||
|
|
||||||
|
"raceTitle": "RACE",
|
||||||
|
"@raceTitle": { "description": "Race panel title" },
|
||||||
|
|
||||||
|
"classSection": "CLASS",
|
||||||
|
"@classSection": { "description": "Class panel title" },
|
||||||
|
|
||||||
|
"bgmLabel": "BGM",
|
||||||
|
"@bgmLabel": { "description": "BGM volume label" },
|
||||||
|
|
||||||
|
"sfxLabel": "SFX",
|
||||||
|
"@sfxLabel": { "description": "SFX volume label" },
|
||||||
|
|
||||||
|
"hpLabel": "HP",
|
||||||
|
"@hpLabel": { "description": "HP bar label" },
|
||||||
|
|
||||||
|
"mpLabel": "MP",
|
||||||
|
"@mpLabel": { "description": "MP bar label" },
|
||||||
|
|
||||||
|
"expLabel": "EXP",
|
||||||
|
"@expLabel": { "description": "EXP bar label" },
|
||||||
|
|
||||||
|
"notifyLevelUp": "LEVEL UP!",
|
||||||
|
"@notifyLevelUp": { "description": "Level up notification title" },
|
||||||
|
|
||||||
|
"notifyLevel": "Level {level}",
|
||||||
|
"@notifyLevel": {
|
||||||
|
"description": "Level notification subtitle",
|
||||||
|
"placeholders": {
|
||||||
|
"level": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"notifyQuestComplete": "QUEST COMPLETE!",
|
||||||
|
"@notifyQuestComplete": { "description": "Quest complete notification title" },
|
||||||
|
|
||||||
|
"notifyPrologueComplete": "PROLOGUE COMPLETE!",
|
||||||
|
"@notifyPrologueComplete": { "description": "Prologue complete notification title" },
|
||||||
|
|
||||||
|
"notifyActComplete": "ACT {number} COMPLETE!",
|
||||||
|
"@notifyActComplete": {
|
||||||
|
"description": "Act complete notification title",
|
||||||
|
"placeholders": {
|
||||||
|
"number": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"notifyNewSpell": "NEW SPELL!",
|
||||||
|
"@notifyNewSpell": { "description": "New spell notification title" },
|
||||||
|
|
||||||
|
"notifyNewEquipment": "NEW EQUIPMENT!",
|
||||||
|
"@notifyNewEquipment": { "description": "New equipment notification title" },
|
||||||
|
|
||||||
|
"notifyBossDefeated": "BOSS DEFEATED!",
|
||||||
|
"@notifyBossDefeated": { "description": "Boss defeated notification title" },
|
||||||
|
|
||||||
|
"rechargeRollsTitle": "RECHARGE ROLLS",
|
||||||
|
"@rechargeRollsTitle": { "description": "Recharge rolls dialog title" },
|
||||||
|
|
||||||
|
"rechargeRollsFree": "Recharge 5 rolls for free?",
|
||||||
|
"@rechargeRollsFree": { "description": "Recharge rolls free user message" },
|
||||||
|
|
||||||
|
"rechargeRollsAd": "Watch an ad to recharge 5 rolls?",
|
||||||
|
"@rechargeRollsAd": { "description": "Recharge rolls ad message" },
|
||||||
|
|
||||||
|
"debugTitle": "DEBUG",
|
||||||
|
"@debugTitle": { "description": "Debug section title" },
|
||||||
|
|
||||||
|
"debugCheatsTitle": "DEBUG CHEATS",
|
||||||
|
"@debugCheatsTitle": { "description": "Debug cheats section title" },
|
||||||
|
|
||||||
|
"debugToolsTitle": "DEBUG TOOLS",
|
||||||
|
"@debugToolsTitle": { "description": "Debug tools section title" },
|
||||||
|
|
||||||
|
"debugDeveloperTools": "DEVELOPER TOOLS",
|
||||||
|
"@debugDeveloperTools": { "description": "Developer tools header" },
|
||||||
|
|
||||||
|
"debugSkipTask": "SKIP TASK (L+1)",
|
||||||
|
"@debugSkipTask": { "description": "Skip task cheat label" },
|
||||||
|
|
||||||
|
"debugSkipTaskDesc": "Complete task instantly",
|
||||||
|
"@debugSkipTaskDesc": { "description": "Skip task cheat description" },
|
||||||
|
|
||||||
|
"debugSkipQuest": "SKIP QUEST (Q!)",
|
||||||
|
"@debugSkipQuest": { "description": "Skip quest cheat label" },
|
||||||
|
|
||||||
|
"debugSkipQuestDesc": "Complete quest instantly",
|
||||||
|
"@debugSkipQuestDesc": { "description": "Skip quest cheat description" },
|
||||||
|
|
||||||
|
"debugSkipAct": "SKIP ACT (P!)",
|
||||||
|
"@debugSkipAct": { "description": "Skip act cheat label" },
|
||||||
|
|
||||||
|
"debugSkipActDesc": "Complete act instantly",
|
||||||
|
"@debugSkipActDesc": { "description": "Skip act cheat description" },
|
||||||
|
|
||||||
|
"debugCreateTestCharacter": "CREATE TEST CHARACTER",
|
||||||
|
"@debugCreateTestCharacter": { "description": "Create test character button" },
|
||||||
|
|
||||||
|
"debugCreateTestCharacterDesc": "Register Level 100 character to Hall of Fame",
|
||||||
|
"@debugCreateTestCharacterDesc": { "description": "Create test character description" },
|
||||||
|
|
||||||
|
"debugCreateTestCharacterTitle": "CREATE TEST CHARACTER?",
|
||||||
|
"@debugCreateTestCharacterTitle": { "description": "Create test character dialog title" },
|
||||||
|
|
||||||
|
"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.",
|
||||||
|
"@debugCreateTestCharacterMessage": { "description": "Create test character confirmation message" },
|
||||||
|
|
||||||
|
"debugTurbo": "DEBUG: TURBO (20x)",
|
||||||
|
"@debugTurbo": { "description": "Debug turbo mode label" },
|
||||||
|
|
||||||
|
"debugIapPurchased": "IAP PURCHASED",
|
||||||
|
"@debugIapPurchased": { "description": "IAP purchased debug toggle" },
|
||||||
|
|
||||||
|
"debugIapPurchasedDesc": "ON: Behave as paid user (ads removed)",
|
||||||
|
"@debugIapPurchasedDesc": { "description": "IAP purchased debug description" },
|
||||||
|
|
||||||
|
"debugOfflineHours": "OFFLINE HOURS",
|
||||||
|
"@debugOfflineHours": { "description": "Offline hours debug label" },
|
||||||
|
|
||||||
|
"debugOfflineHoursDesc": "Test return rewards (applies on restart)",
|
||||||
|
"@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" },
|
||||||
|
|
||||||
|
"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" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +1,224 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "ja",
|
"@@locale": "ja",
|
||||||
|
|
||||||
"appTitle": "ASCII NEVER DIE",
|
"appTitle": "アスキー ネバー ダイ",
|
||||||
"tagNoNetwork": "No network",
|
"tagNoNetwork": "オフライン",
|
||||||
"tagIdleRpg": "Idle RPG loop",
|
"tagIdleRpg": "放置型RPG",
|
||||||
"tagLocalSaves": "Local saves",
|
"tagLocalSaves": "ローカル保存",
|
||||||
"newCharacter": "New character",
|
"newCharacter": "新規キャラクター",
|
||||||
"loadSave": "Load save",
|
"loadSave": "ロード",
|
||||||
"loadGame": "Load Game",
|
"loadGame": "ゲームをロード",
|
||||||
"viewBuildPlan": "View build plan",
|
"viewBuildPlan": "ビルド計画を見る",
|
||||||
"buildRoadmap": "Build roadmap",
|
"buildRoadmap": "ビルドロードマップ",
|
||||||
"techStack": "Tech stack",
|
"techStack": "技術スタック",
|
||||||
"cancel": "Cancel",
|
"cancel": "キャンセル",
|
||||||
"exitGame": "Exit Game",
|
"exitGame": "ゲーム終了",
|
||||||
"saveProgressQuestion": "Save your progress before leaving?",
|
"saveProgressQuestion": "終了する前にセーブしますか?",
|
||||||
"exitWithoutSaving": "Exit without saving",
|
"exitWithoutSaving": "セーブせずに終了",
|
||||||
"saveAndExit": "Save and Exit",
|
"saveAndExit": "セーブして終了",
|
||||||
"progressQuestTitle": "Progress Quest - {name}",
|
"progressQuestTitle": "アスキー ネバー ダイ - {name}",
|
||||||
"levelUp": "Level Up",
|
"levelUp": "レベルアップ",
|
||||||
"completeQuest": "Complete Quest",
|
"completeQuest": "クエスト完了",
|
||||||
"completePlot": "Complete Plot",
|
"completePlot": "プロット完了",
|
||||||
"characterSheet": "Character Sheet",
|
"characterSheet": "キャラクターシート",
|
||||||
"traits": "Traits",
|
"traits": "特性",
|
||||||
"stats": "Stats",
|
"stats": "能力値",
|
||||||
"experience": "Experience",
|
"experience": "経験値",
|
||||||
"xpNeededForNextLevel": "XP needed for next level",
|
"xpNeededForNextLevel": "次のレベルまでの必要XP",
|
||||||
"spellBook": "スキル",
|
"spellBook": "スキル",
|
||||||
"noSpellsYet": "習得したスキルがありません",
|
"noSpellsYet": "習得したスキルがありません",
|
||||||
"equipment": "Equipment",
|
"equipment": "装備",
|
||||||
"inventory": "Inventory",
|
"inventory": "インベントリ",
|
||||||
"encumbrance": "Encumbrance",
|
"encumbrance": "積載量",
|
||||||
"combatLog": "戦闘ログ",
|
"combatLog": "戦闘ログ",
|
||||||
"plotDevelopment": "Plot Development",
|
"plotDevelopment": "ストーリー進行",
|
||||||
"quests": "Quests",
|
"quests": "クエスト",
|
||||||
"traitName": "Name",
|
"traitName": "名前",
|
||||||
"traitRace": "Race",
|
"traitRace": "種族",
|
||||||
"traitClass": "Class",
|
"traitClass": "職業",
|
||||||
"traitLevel": "Level",
|
"traitLevel": "レベル",
|
||||||
"statStr": "STR",
|
"statStr": "STR",
|
||||||
"statCon": "CON",
|
"statCon": "CON",
|
||||||
"statDex": "DEX",
|
"statDex": "DEX",
|
||||||
"statInt": "INT",
|
"statInt": "INT",
|
||||||
"statWis": "WIS",
|
"statWis": "WIS",
|
||||||
"statCha": "CHA",
|
"statCha": "CHA",
|
||||||
"statHpMax": "HP Max",
|
"statHpMax": "HP最大",
|
||||||
"statMpMax": "MP Max",
|
"statMpMax": "MP最大",
|
||||||
"equipWeapon": "Weapon",
|
"equipWeapon": "武器",
|
||||||
"equipShield": "Shield",
|
"equipShield": "盾",
|
||||||
"equipHelm": "Helm",
|
"equipHelm": "兜",
|
||||||
"equipHauberk": "Hauberk",
|
"equipHauberk": "鎧",
|
||||||
"equipBrassairts": "Brassairts",
|
"equipBrassairts": "肩当て",
|
||||||
"equipVambraces": "Vambraces",
|
"equipVambraces": "腕甲",
|
||||||
"equipGauntlets": "Gauntlets",
|
"equipGauntlets": "篭手",
|
||||||
"equipGambeson": "Gambeson",
|
"equipGambeson": "防護服",
|
||||||
"equipCuisses": "Cuisses",
|
"equipCuisses": "腿当て",
|
||||||
"equipGreaves": "Greaves",
|
"equipGreaves": "脛当て",
|
||||||
"equipSollerets": "Sollerets",
|
"equipSollerets": "鉄靴",
|
||||||
"gold": "コイン",
|
"gold": "コイン",
|
||||||
"goldAmount": "コイン: {amount}",
|
"goldAmount": "コイン: {amount}",
|
||||||
"prologue": "Prologue",
|
"prologue": "プロローグ",
|
||||||
"actNumber": "Act {number}",
|
"actNumber": "第{number}幕",
|
||||||
"noActiveQuests": "No active quests",
|
"noActiveQuests": "進行中のクエストなし",
|
||||||
"questNumber": "Quest #{number}",
|
"questNumber": "クエスト #{number}",
|
||||||
"welcomeMessage": "Welcome to Progress Quest!",
|
"welcomeMessage": "ASCII NEVER DIEへようこそ!",
|
||||||
"noSavedGames": "No saved games found.",
|
"noSavedGames": "セーブデータがありません。",
|
||||||
"loadError": "Failed to load save file: {error}",
|
"loadError": "セーブファイルの読み込みに失敗しました: {error}",
|
||||||
"name": "Name",
|
"name": "名前",
|
||||||
"generateName": "Generate Name",
|
"generateName": "名前を生成",
|
||||||
"total": "Total",
|
"total": "合計",
|
||||||
"unroll": "Unroll",
|
"unroll": "元に戻す",
|
||||||
"roll": "Roll",
|
"roll": "ロール",
|
||||||
"race": "Race",
|
"race": "種族",
|
||||||
"classTitle": "Class",
|
"classTitle": "職業",
|
||||||
"percentComplete": "{percent}% complete",
|
"percentComplete": "{percent}% 完了",
|
||||||
"newCharacterTitle": "ASCII NEVER DIE - New Character",
|
"newCharacterTitle": "アスキー ネバー ダイ - 新規キャラクター",
|
||||||
"soldButton": "Sold!"
|
"soldButton": "決定!",
|
||||||
|
|
||||||
|
"endingCongratulations": "★ おめでとうございます ★",
|
||||||
|
"endingGameComplete": "ゲームをクリアしました!",
|
||||||
|
"endingTheHero": "英雄",
|
||||||
|
"endingLevelFormat": "レベル {level}",
|
||||||
|
"endingJourneyStats": "冒険の記録",
|
||||||
|
"endingMonstersSlain": "倒したモンスター",
|
||||||
|
"endingQuestsCompleted": "完了したクエスト",
|
||||||
|
"endingPlayTime": "プレイ時間",
|
||||||
|
"endingFinalStats": "最終ステータス",
|
||||||
|
"endingCredits": "クレジット",
|
||||||
|
"endingThankYou": "プレイしていただきありがとうございます!",
|
||||||
|
"endingLegendLivesOn": "あなたの伝説は続く...",
|
||||||
|
"endingHallOfFameLine1": "あなたの英雄的な功績は",
|
||||||
|
"endingHallOfFameLine2": "殿堂に記録されます",
|
||||||
|
"endingHallOfFameButton": "殿堂入り",
|
||||||
|
"endingSkip": "スキップ",
|
||||||
|
"endingTapToSkip": "タップでスキップ",
|
||||||
|
"endingHoldToSpeedUp": "長押しで高速スクロール",
|
||||||
|
|
||||||
|
"menuTitle": "メニュー",
|
||||||
|
"optionsTitle": "オプション",
|
||||||
|
"soundTitle": "サウンド",
|
||||||
|
"controlSection": "操作",
|
||||||
|
"infoSection": "情報",
|
||||||
|
"settingsSection": "設定",
|
||||||
|
"saveExitSection": "セーブ / 終了",
|
||||||
|
"ok": "OK",
|
||||||
|
"rechargeButton": "チャージ",
|
||||||
|
"createButton": "作成",
|
||||||
|
"previewTitle": "プレビュー",
|
||||||
|
"nameTitle": "名前",
|
||||||
|
"statsTitle": "能力値",
|
||||||
|
"raceTitle": "種族",
|
||||||
|
"classSection": "職業",
|
||||||
|
"bgmLabel": "BGM",
|
||||||
|
"sfxLabel": "効果音",
|
||||||
|
"hpLabel": "HP",
|
||||||
|
"mpLabel": "MP",
|
||||||
|
"expLabel": "EXP",
|
||||||
|
"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": "警告"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,11 +68,157 @@
|
|||||||
"name": "이름",
|
"name": "이름",
|
||||||
"generateName": "이름 생성",
|
"generateName": "이름 생성",
|
||||||
"total": "합계",
|
"total": "합계",
|
||||||
"unroll": "펼치기",
|
"unroll": "되돌리기",
|
||||||
"roll": "굴리기",
|
"roll": "굴리기",
|
||||||
"race": "종족",
|
"race": "종족",
|
||||||
"classTitle": "직업",
|
"classTitle": "직업",
|
||||||
"percentComplete": "{percent}% 완료",
|
"percentComplete": "{percent}% 완료",
|
||||||
"newCharacterTitle": "아스키 네버 다이 - 새 캐릭터",
|
"newCharacterTitle": "아스키 네버 다이 - 새 캐릭터",
|
||||||
"soldButton": "확인!"
|
"soldButton": "확인!",
|
||||||
|
|
||||||
|
"endingCongratulations": "★ 축하합니다 ★",
|
||||||
|
"endingGameComplete": "게임을 클리어하셨습니다!",
|
||||||
|
"endingTheHero": "영웅",
|
||||||
|
"endingLevelFormat": "레벨 {level}",
|
||||||
|
"endingJourneyStats": "여정 통계",
|
||||||
|
"endingMonstersSlain": "처치한 몬스터",
|
||||||
|
"endingQuestsCompleted": "완료한 퀘스트",
|
||||||
|
"endingPlayTime": "플레이 시간",
|
||||||
|
"endingFinalStats": "최종 능력치",
|
||||||
|
"endingCredits": "크레딧",
|
||||||
|
"endingThankYou": "플레이해 주셔서 감사합니다!",
|
||||||
|
"endingLegendLivesOn": "당신의 전설은 계속됩니다...",
|
||||||
|
"endingHallOfFameLine1": "당신의 영웅적인 업적이",
|
||||||
|
"endingHallOfFameLine2": "명예의 전당에 기록됩니다",
|
||||||
|
"endingHallOfFameButton": "명예의 전당",
|
||||||
|
"endingSkip": "건너뛰기",
|
||||||
|
"endingTapToSkip": "탭하여 건너뛰기",
|
||||||
|
"endingHoldToSpeedUp": "길게 누르면 빨리 스크롤",
|
||||||
|
|
||||||
|
"menuTitle": "메뉴",
|
||||||
|
"optionsTitle": "옵션",
|
||||||
|
"soundTitle": "사운드",
|
||||||
|
"controlSection": "제어",
|
||||||
|
"infoSection": "정보",
|
||||||
|
"settingsSection": "설정",
|
||||||
|
"saveExitSection": "저장 / 종료",
|
||||||
|
"ok": "확인",
|
||||||
|
"rechargeButton": "충전",
|
||||||
|
"createButton": "생성",
|
||||||
|
"previewTitle": "미리보기",
|
||||||
|
"nameTitle": "이름",
|
||||||
|
"statsTitle": "능력치",
|
||||||
|
"raceTitle": "종족",
|
||||||
|
"classSection": "직업",
|
||||||
|
"bgmLabel": "BGM",
|
||||||
|
"sfxLabel": "효과음",
|
||||||
|
"hpLabel": "HP",
|
||||||
|
"mpLabel": "MP",
|
||||||
|
"expLabel": "경험치",
|
||||||
|
"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": "경고"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import 'package:intl/intl.dart' as intl;
|
|||||||
import 'app_localizations_en.dart';
|
import 'app_localizations_en.dart';
|
||||||
import 'app_localizations_ja.dart';
|
import 'app_localizations_ja.dart';
|
||||||
import 'app_localizations_ko.dart';
|
import 'app_localizations_ko.dart';
|
||||||
import 'app_localizations_zh.dart';
|
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
|
|
||||||
@@ -98,7 +97,6 @@ abstract class L10n {
|
|||||||
Locale('en'),
|
Locale('en'),
|
||||||
Locale('ja'),
|
Locale('ja'),
|
||||||
Locale('ko'),
|
Locale('ko'),
|
||||||
Locale('zh'),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Application title
|
/// Application title
|
||||||
@@ -503,10 +501,10 @@ abstract class L10n {
|
|||||||
/// **'Total'**
|
/// **'Total'**
|
||||||
String get total;
|
String get total;
|
||||||
|
|
||||||
/// Unroll button
|
/// Undo button for stat reroll
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Unroll'**
|
/// **'Undo'**
|
||||||
String get unroll;
|
String get unroll;
|
||||||
|
|
||||||
/// Roll button
|
/// Roll button
|
||||||
@@ -544,6 +542,852 @@ abstract class L10n {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Sold!'**
|
/// **'Sold!'**
|
||||||
String get soldButton;
|
String get soldButton;
|
||||||
|
|
||||||
|
/// Victory overlay congratulations
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'★ CONGRATULATIONS ★'**
|
||||||
|
String get endingCongratulations;
|
||||||
|
|
||||||
|
/// Game completion message
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'You have completed the game!'**
|
||||||
|
String get endingGameComplete;
|
||||||
|
|
||||||
|
/// Hero section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'THE HERO'**
|
||||||
|
String get endingTheHero;
|
||||||
|
|
||||||
|
/// Level display format
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Level {level}'**
|
||||||
|
String endingLevelFormat(int level);
|
||||||
|
|
||||||
|
/// Journey statistics section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'JOURNEY STATISTICS'**
|
||||||
|
String get endingJourneyStats;
|
||||||
|
|
||||||
|
/// Monsters killed stat label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Monsters Slain'**
|
||||||
|
String get endingMonstersSlain;
|
||||||
|
|
||||||
|
/// Quests completed stat label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Quests Completed'**
|
||||||
|
String get endingQuestsCompleted;
|
||||||
|
|
||||||
|
/// Play time stat label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Play Time'**
|
||||||
|
String get endingPlayTime;
|
||||||
|
|
||||||
|
/// Final stats section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'FINAL STATS'**
|
||||||
|
String get endingFinalStats;
|
||||||
|
|
||||||
|
/// Credits section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'CREDITS'**
|
||||||
|
String get endingCredits;
|
||||||
|
|
||||||
|
/// Thank you message
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Thank you for playing!'**
|
||||||
|
String get endingThankYou;
|
||||||
|
|
||||||
|
/// Legend message
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your legend lives on...'**
|
||||||
|
String get endingLegendLivesOn;
|
||||||
|
|
||||||
|
/// Hall of fame message line 1
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your heroic deeds will be'**
|
||||||
|
String get endingHallOfFameLine1;
|
||||||
|
|
||||||
|
/// Hall of fame message line 2
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'remembered in the Hall of Fame'**
|
||||||
|
String get endingHallOfFameLine2;
|
||||||
|
|
||||||
|
/// Hall of fame button
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'HALL OF FAME'**
|
||||||
|
String get endingHallOfFameButton;
|
||||||
|
|
||||||
|
/// Skip button
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SKIP'**
|
||||||
|
String get endingSkip;
|
||||||
|
|
||||||
|
/// Tap to skip hint
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'TAP TO SKIP'**
|
||||||
|
String get endingTapToSkip;
|
||||||
|
|
||||||
|
/// Hold to speed up scrolling hint
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'HOLD TO SPEED UP'**
|
||||||
|
String get endingHoldToSpeedUp;
|
||||||
|
|
||||||
|
/// Menu panel title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'MENU'**
|
||||||
|
String get menuTitle;
|
||||||
|
|
||||||
|
/// Options menu title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'OPTIONS'**
|
||||||
|
String get optionsTitle;
|
||||||
|
|
||||||
|
/// Sound dialog title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SOUND'**
|
||||||
|
String get soundTitle;
|
||||||
|
|
||||||
|
/// Control section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'CONTROL'**
|
||||||
|
String get controlSection;
|
||||||
|
|
||||||
|
/// Info section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'INFO'**
|
||||||
|
String get infoSection;
|
||||||
|
|
||||||
|
/// Settings section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SETTINGS'**
|
||||||
|
String get settingsSection;
|
||||||
|
|
||||||
|
/// Save/Exit section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SAVE / EXIT'**
|
||||||
|
String get saveExitSection;
|
||||||
|
|
||||||
|
/// OK button
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'OK'**
|
||||||
|
String get ok;
|
||||||
|
|
||||||
|
/// Recharge button
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'RECHARGE'**
|
||||||
|
String get rechargeButton;
|
||||||
|
|
||||||
|
/// Create button
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'CREATE'**
|
||||||
|
String get createButton;
|
||||||
|
|
||||||
|
/// Preview panel title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'PREVIEW'**
|
||||||
|
String get previewTitle;
|
||||||
|
|
||||||
|
/// Name panel title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'NAME'**
|
||||||
|
String get nameTitle;
|
||||||
|
|
||||||
|
/// Stats panel title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'STATS'**
|
||||||
|
String get statsTitle;
|
||||||
|
|
||||||
|
/// Race panel title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'RACE'**
|
||||||
|
String get raceTitle;
|
||||||
|
|
||||||
|
/// Class panel title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'CLASS'**
|
||||||
|
String get classSection;
|
||||||
|
|
||||||
|
/// BGM volume label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'BGM'**
|
||||||
|
String get bgmLabel;
|
||||||
|
|
||||||
|
/// SFX volume label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SFX'**
|
||||||
|
String get sfxLabel;
|
||||||
|
|
||||||
|
/// HP bar label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'HP'**
|
||||||
|
String get hpLabel;
|
||||||
|
|
||||||
|
/// MP bar label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'MP'**
|
||||||
|
String get mpLabel;
|
||||||
|
|
||||||
|
/// EXP bar label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'EXP'**
|
||||||
|
String get expLabel;
|
||||||
|
|
||||||
|
/// Level up notification title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'LEVEL UP!'**
|
||||||
|
String get notifyLevelUp;
|
||||||
|
|
||||||
|
/// Level notification subtitle
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Level {level}'**
|
||||||
|
String notifyLevel(int level);
|
||||||
|
|
||||||
|
/// Quest complete notification title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'QUEST COMPLETE!'**
|
||||||
|
String get notifyQuestComplete;
|
||||||
|
|
||||||
|
/// Prologue complete notification title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'PROLOGUE COMPLETE!'**
|
||||||
|
String get notifyPrologueComplete;
|
||||||
|
|
||||||
|
/// Act complete notification title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'ACT {number} COMPLETE!'**
|
||||||
|
String notifyActComplete(int number);
|
||||||
|
|
||||||
|
/// New spell notification title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'NEW SPELL!'**
|
||||||
|
String get notifyNewSpell;
|
||||||
|
|
||||||
|
/// New equipment notification title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'NEW EQUIPMENT!'**
|
||||||
|
String get notifyNewEquipment;
|
||||||
|
|
||||||
|
/// Boss defeated notification title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'BOSS DEFEATED!'**
|
||||||
|
String get notifyBossDefeated;
|
||||||
|
|
||||||
|
/// Recharge rolls dialog title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'RECHARGE ROLLS'**
|
||||||
|
String get rechargeRollsTitle;
|
||||||
|
|
||||||
|
/// Recharge rolls free user message
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Recharge 5 rolls for free?'**
|
||||||
|
String get rechargeRollsFree;
|
||||||
|
|
||||||
|
/// Recharge rolls ad message
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Watch an ad to recharge 5 rolls?'**
|
||||||
|
String get rechargeRollsAd;
|
||||||
|
|
||||||
|
/// Debug section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'DEBUG'**
|
||||||
|
String get debugTitle;
|
||||||
|
|
||||||
|
/// Debug cheats section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'DEBUG CHEATS'**
|
||||||
|
String get debugCheatsTitle;
|
||||||
|
|
||||||
|
/// Debug tools section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'DEBUG TOOLS'**
|
||||||
|
String get debugToolsTitle;
|
||||||
|
|
||||||
|
/// Developer tools header
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'DEVELOPER TOOLS'**
|
||||||
|
String get debugDeveloperTools;
|
||||||
|
|
||||||
|
/// Skip task cheat label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SKIP TASK (L+1)'**
|
||||||
|
String get debugSkipTask;
|
||||||
|
|
||||||
|
/// Skip task cheat description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Complete task instantly'**
|
||||||
|
String get debugSkipTaskDesc;
|
||||||
|
|
||||||
|
/// Skip quest cheat label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SKIP QUEST (Q!)'**
|
||||||
|
String get debugSkipQuest;
|
||||||
|
|
||||||
|
/// Skip quest cheat description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Complete quest instantly'**
|
||||||
|
String get debugSkipQuestDesc;
|
||||||
|
|
||||||
|
/// Skip act cheat label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SKIP ACT (P!)'**
|
||||||
|
String get debugSkipAct;
|
||||||
|
|
||||||
|
/// Skip act cheat description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Complete act instantly'**
|
||||||
|
String get debugSkipActDesc;
|
||||||
|
|
||||||
|
/// Create test character button
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'CREATE TEST CHARACTER'**
|
||||||
|
String get debugCreateTestCharacter;
|
||||||
|
|
||||||
|
/// Create test character description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Register Level 100 character to Hall of Fame'**
|
||||||
|
String get debugCreateTestCharacterDesc;
|
||||||
|
|
||||||
|
/// Create test character dialog title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'CREATE TEST CHARACTER?'**
|
||||||
|
String get debugCreateTestCharacterTitle;
|
||||||
|
|
||||||
|
/// Create test character confirmation message
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'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.'**
|
||||||
|
String get debugCreateTestCharacterMessage;
|
||||||
|
|
||||||
|
/// Debug turbo mode label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'DEBUG: TURBO (20x)'**
|
||||||
|
String get debugTurbo;
|
||||||
|
|
||||||
|
/// IAP purchased debug toggle
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'IAP PURCHASED'**
|
||||||
|
String get debugIapPurchased;
|
||||||
|
|
||||||
|
/// IAP purchased debug description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'ON: Behave as paid user (ads removed)'**
|
||||||
|
String get debugIapPurchasedDesc;
|
||||||
|
|
||||||
|
/// Offline hours debug label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'OFFLINE HOURS'**
|
||||||
|
String get debugOfflineHours;
|
||||||
|
|
||||||
|
/// Offline hours debug description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Test return rewards (applies on restart)'**
|
||||||
|
String get debugOfflineHoursDesc;
|
||||||
|
|
||||||
|
/// Test character creation description
|
||||||
|
///
|
||||||
|
/// 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> {
|
class _L10nDelegate extends LocalizationsDelegate<L10n> {
|
||||||
@@ -556,7 +1400,7 @@ class _L10nDelegate extends LocalizationsDelegate<L10n> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool isSupported(Locale locale) =>
|
bool isSupported(Locale locale) =>
|
||||||
<String>['en', 'ja', 'ko', 'zh'].contains(locale.languageCode);
|
<String>['en', 'ja', 'ko'].contains(locale.languageCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldReload(_L10nDelegate old) => false;
|
bool shouldReload(_L10nDelegate old) => false;
|
||||||
@@ -571,8 +1415,6 @@ L10n lookupL10n(Locale locale) {
|
|||||||
return L10nJa();
|
return L10nJa();
|
||||||
case 'ko':
|
case 'ko':
|
||||||
return L10nKo();
|
return L10nKo();
|
||||||
case 'zh':
|
|
||||||
return L10nZh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw FlutterError(
|
throw FlutterError(
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ class L10nEn extends L10n {
|
|||||||
String get total => 'Total';
|
String get total => 'Total';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get unroll => 'Unroll';
|
String get unroll => 'Undo';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get roll => 'Roll';
|
String get roll => 'Roll';
|
||||||
@@ -241,4 +241,443 @@ class L10nEn extends L10n {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get soldButton => 'Sold!';
|
String get soldButton => 'Sold!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingCongratulations => '★ CONGRATULATIONS ★';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingGameComplete => 'You have completed the game!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingTheHero => 'THE HERO';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String endingLevelFormat(int level) {
|
||||||
|
return 'Level $level';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingJourneyStats => 'JOURNEY STATISTICS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingMonstersSlain => 'Monsters Slain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingQuestsCompleted => 'Quests Completed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingPlayTime => 'Play Time';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingFinalStats => 'FINAL STATS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingCredits => 'CREDITS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingThankYou => 'Thank you for playing!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingLegendLivesOn => 'Your legend lives on...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingHallOfFameLine1 => 'Your heroic deeds will be';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingHallOfFameLine2 => 'remembered in the Hall of Fame';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingHallOfFameButton => 'HALL OF FAME';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingSkip => 'SKIP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingTapToSkip => 'TAP TO SKIP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingHoldToSpeedUp => 'HOLD TO SPEED UP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get menuTitle => 'MENU';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsTitle => 'OPTIONS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get soundTitle => 'SOUND';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get controlSection => 'CONTROL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get infoSection => 'INFO';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsSection => 'SETTINGS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveExitSection => 'SAVE / EXIT';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ok => 'OK';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeButton => 'RECHARGE';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get createButton => 'CREATE';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewTitle => 'PREVIEW';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nameTitle => 'NAME';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTitle => 'STATS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get raceTitle => 'RACE';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get classSection => 'CLASS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bgmLabel => 'BGM';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sfxLabel => 'SFX';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hpLabel => 'HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mpLabel => 'MP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get expLabel => 'EXP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyLevelUp => 'LEVEL UP!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifyLevel(int level) {
|
||||||
|
return 'Level $level';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyQuestComplete => 'QUEST COMPLETE!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyPrologueComplete => 'PROLOGUE COMPLETE!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifyActComplete(int number) {
|
||||||
|
return 'ACT $number COMPLETE!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewSpell => 'NEW SPELL!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewEquipment => 'NEW EQUIPMENT!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyBossDefeated => 'BOSS DEFEATED!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsTitle => 'RECHARGE ROLLS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsFree => 'Recharge 5 rolls for free?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsAd => 'Watch an ad to recharge 5 rolls?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugTitle => 'DEBUG';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCheatsTitle => 'DEBUG CHEATS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugToolsTitle => 'DEBUG TOOLS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugDeveloperTools => 'DEVELOPER TOOLS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipTask => 'SKIP TASK (L+1)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipTaskDesc => 'Complete task instantly';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipQuest => 'SKIP QUEST (Q!)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipQuestDesc => 'Complete quest instantly';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipAct => 'SKIP ACT (P!)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipActDesc => 'Complete act instantly';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacter => 'CREATE TEST CHARACTER';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterDesc =>
|
||||||
|
'Register Level 100 character to Hall of Fame';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterTitle => 'CREATE TEST CHARACTER?';
|
||||||
|
|
||||||
|
@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.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugTurbo => 'DEBUG: TURBO (20x)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugIapPurchased => 'IAP PURCHASED';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugIapPurchasedDesc => 'ON: Behave as paid user (ads removed)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugOfflineHours => 'OFFLINE HOURS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugOfflineHoursDesc =>
|
||||||
|
'Test return rewards (applies on restart)';
|
||||||
|
|
||||||
|
@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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,78 +9,78 @@ class L10nJa extends L10n {
|
|||||||
L10nJa([String locale = 'ja']) : super(locale);
|
L10nJa([String locale = 'ja']) : super(locale);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appTitle => 'ASCII NEVER DIE';
|
String get appTitle => 'アスキー ネバー ダイ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tagNoNetwork => 'No network';
|
String get tagNoNetwork => 'オフライン';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tagIdleRpg => 'Idle RPG loop';
|
String get tagIdleRpg => '放置型RPG';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tagLocalSaves => 'Local saves';
|
String get tagLocalSaves => 'ローカル保存';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get newCharacter => 'New character';
|
String get newCharacter => '新規キャラクター';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loadSave => 'Load save';
|
String get loadSave => 'ロード';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loadGame => 'Load Game';
|
String get loadGame => 'ゲームをロード';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get viewBuildPlan => 'View build plan';
|
String get viewBuildPlan => 'ビルド計画を見る';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get buildRoadmap => 'Build roadmap';
|
String get buildRoadmap => 'ビルドロードマップ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get techStack => 'Tech stack';
|
String get techStack => '技術スタック';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cancel => 'Cancel';
|
String get cancel => 'キャンセル';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get exitGame => 'Exit Game';
|
String get exitGame => 'ゲーム終了';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get saveProgressQuestion => 'Save your progress before leaving?';
|
String get saveProgressQuestion => '終了する前にセーブしますか?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get exitWithoutSaving => 'Exit without saving';
|
String get exitWithoutSaving => 'セーブせずに終了';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get saveAndExit => 'Save and Exit';
|
String get saveAndExit => 'セーブして終了';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String progressQuestTitle(String name) {
|
String progressQuestTitle(String name) {
|
||||||
return 'Progress Quest - $name';
|
return 'アスキー ネバー ダイ - $name';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get levelUp => 'Level Up';
|
String get levelUp => 'レベルアップ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get completeQuest => 'Complete Quest';
|
String get completeQuest => 'クエスト完了';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get completePlot => 'Complete Plot';
|
String get completePlot => 'プロット完了';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get characterSheet => 'Character Sheet';
|
String get characterSheet => 'キャラクターシート';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get traits => 'Traits';
|
String get traits => '特性';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stats => 'Stats';
|
String get stats => '能力値';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get experience => 'Experience';
|
String get experience => '経験値';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get xpNeededForNextLevel => 'XP needed for next level';
|
String get xpNeededForNextLevel => '次のレベルまでの必要XP';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get spellBook => 'スキル';
|
String get spellBook => 'スキル';
|
||||||
@@ -89,34 +89,34 @@ class L10nJa extends L10n {
|
|||||||
String get noSpellsYet => '習得したスキルがありません';
|
String get noSpellsYet => '習得したスキルがありません';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipment => 'Equipment';
|
String get equipment => '装備';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get inventory => 'Inventory';
|
String get inventory => 'インベントリ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get encumbrance => 'Encumbrance';
|
String get encumbrance => '積載量';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get combatLog => '戦闘ログ';
|
String get combatLog => '戦闘ログ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get plotDevelopment => 'Plot Development';
|
String get plotDevelopment => 'ストーリー進行';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get quests => 'Quests';
|
String get quests => 'クエスト';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get traitName => 'Name';
|
String get traitName => '名前';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get traitRace => 'Race';
|
String get traitRace => '種族';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get traitClass => 'Class';
|
String get traitClass => '職業';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get traitLevel => 'Level';
|
String get traitLevel => 'レベル';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get statStr => 'STR';
|
String get statStr => 'STR';
|
||||||
@@ -137,43 +137,43 @@ class L10nJa extends L10n {
|
|||||||
String get statCha => 'CHA';
|
String get statCha => 'CHA';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get statHpMax => 'HP Max';
|
String get statHpMax => 'HP最大';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get statMpMax => 'MP Max';
|
String get statMpMax => 'MP最大';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipWeapon => 'Weapon';
|
String get equipWeapon => '武器';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipShield => 'Shield';
|
String get equipShield => '盾';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipHelm => 'Helm';
|
String get equipHelm => '兜';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipHauberk => 'Hauberk';
|
String get equipHauberk => '鎧';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipBrassairts => 'Brassairts';
|
String get equipBrassairts => '肩当て';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipVambraces => 'Vambraces';
|
String get equipVambraces => '腕甲';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipGauntlets => 'Gauntlets';
|
String get equipGauntlets => '篭手';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipGambeson => 'Gambeson';
|
String get equipGambeson => '防護服';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipCuisses => 'Cuisses';
|
String get equipCuisses => '腿当て';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipGreaves => 'Greaves';
|
String get equipGreaves => '脛当て';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipSollerets => 'Sollerets';
|
String get equipSollerets => '鉄靴';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get gold => 'コイン';
|
String get gold => 'コイン';
|
||||||
@@ -184,61 +184,497 @@ class L10nJa extends L10n {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get prologue => 'Prologue';
|
String get prologue => 'プロローグ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String actNumber(String number) {
|
String actNumber(String number) {
|
||||||
return 'Act $number';
|
return '第$number幕';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noActiveQuests => 'No active quests';
|
String get noActiveQuests => '進行中のクエストなし';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String questNumber(int number) {
|
String questNumber(int number) {
|
||||||
return 'Quest #$number';
|
return 'クエスト #$number';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeMessage => 'Welcome to Progress Quest!';
|
String get welcomeMessage => 'ASCII NEVER DIEへようこそ!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noSavedGames => 'No saved games found.';
|
String get noSavedGames => 'セーブデータがありません。';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String loadError(String error) {
|
String loadError(String error) {
|
||||||
return 'Failed to load save file: $error';
|
return 'セーブファイルの読み込みに失敗しました: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get name => 'Name';
|
String get name => '名前';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get generateName => 'Generate Name';
|
String get generateName => '名前を生成';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get total => 'Total';
|
String get total => '合計';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get unroll => 'Unroll';
|
String get unroll => '元に戻す';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get roll => 'Roll';
|
String get roll => 'ロール';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get race => 'Race';
|
String get race => '種族';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get classTitle => 'Class';
|
String get classTitle => '職業';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String percentComplete(int percent) {
|
String percentComplete(int percent) {
|
||||||
return '$percent% complete';
|
return '$percent% 完了';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get newCharacterTitle => 'ASCII NEVER DIE - New Character';
|
String get newCharacterTitle => 'アスキー ネバー ダイ - 新規キャラクター';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get soldButton => 'Sold!';
|
String get soldButton => '決定!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingCongratulations => '★ おめでとうございます ★';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingGameComplete => 'ゲームをクリアしました!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingTheHero => '英雄';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String endingLevelFormat(int level) {
|
||||||
|
return 'レベル $level';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingJourneyStats => '冒険の記録';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingMonstersSlain => '倒したモンスター';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingQuestsCompleted => '完了したクエスト';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingPlayTime => 'プレイ時間';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingFinalStats => '最終ステータス';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingCredits => 'クレジット';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingThankYou => 'プレイしていただきありがとうございます!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingLegendLivesOn => 'あなたの伝説は続く...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingHallOfFameLine1 => 'あなたの英雄的な功績は';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingHallOfFameLine2 => '殿堂に記録されます';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingHallOfFameButton => '殿堂入り';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingSkip => 'スキップ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingTapToSkip => 'タップでスキップ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingHoldToSpeedUp => '長押しで高速スクロール';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get menuTitle => 'メニュー';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsTitle => 'オプション';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get soundTitle => 'サウンド';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get controlSection => '操作';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get infoSection => '情報';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsSection => '設定';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveExitSection => 'セーブ / 終了';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ok => 'OK';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeButton => 'チャージ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get createButton => '作成';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewTitle => 'プレビュー';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nameTitle => '名前';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTitle => '能力値';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get raceTitle => '種族';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get classSection => '職業';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bgmLabel => 'BGM';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sfxLabel => '効果音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hpLabel => 'HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mpLabel => 'MP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get expLabel => 'EXP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyLevelUp => 'レベルアップ!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifyLevel(int level) {
|
||||||
|
return 'レベル $level';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyQuestComplete => 'クエスト完了!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyPrologueComplete => 'プロローグ完了!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifyActComplete(int number) {
|
||||||
|
return '第$number幕 完了!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewSpell => '新しいスキル!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewEquipment => '新しい装備!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyBossDefeated => 'ボス撃破!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsTitle => 'ロール回数チャージ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsFree => '無料で5回チャージしますか?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsAd => '広告を見て5回チャージしますか?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugTitle => 'デバッグ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCheatsTitle => 'デバッグチート';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugToolsTitle => 'デバッグツール';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugDeveloperTools => '開発者ツール';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipTask => 'タスクスキップ (L+1)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipTaskDesc => 'タスクを即時完了';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipQuest => 'クエストスキップ (Q!)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipQuestDesc => 'クエストを即時完了';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipAct => 'アクトスキップ (P!)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipActDesc => 'アクトを即時完了';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacter => 'テストキャラクター作成';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterDesc => 'レベル100キャラクターを殿堂に登録';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterTitle => 'テストキャラクターを作成しますか?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterMessage =>
|
||||||
|
'現在のキャラクターがレベル100に変換され\n殿堂に登録されます。\n\n⚠️ 現在のセーブファイルは削除されます。\nこの操作は元に戻せません。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugTurbo => 'デバッグ: ターボ (20x)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugIapPurchased => 'IAP購入済み';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugIapPurchasedDesc => 'ON: 有料ユーザーとして動作(広告非表示)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugOfflineHours => 'オフライン時間';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugOfflineHoursDesc => '復帰報酬テスト(再起動時に適用)';
|
||||||
|
|
||||||
|
@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 => '警告';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ class L10nKo extends L10n {
|
|||||||
String get total => '합계';
|
String get total => '합계';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get unroll => '펼치기';
|
String get unroll => '되돌리기';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get roll => '굴리기';
|
String get roll => '굴리기';
|
||||||
@@ -241,4 +241,440 @@ class L10nKo extends L10n {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get soldButton => '확인!';
|
String get soldButton => '확인!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingCongratulations => '★ 축하합니다 ★';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingGameComplete => '게임을 클리어하셨습니다!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingTheHero => '영웅';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String endingLevelFormat(int level) {
|
||||||
|
return '레벨 $level';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingJourneyStats => '여정 통계';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingMonstersSlain => '처치한 몬스터';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingQuestsCompleted => '완료한 퀘스트';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingPlayTime => '플레이 시간';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingFinalStats => '최종 능력치';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingCredits => '크레딧';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingThankYou => '플레이해 주셔서 감사합니다!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingLegendLivesOn => '당신의 전설은 계속됩니다...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingHallOfFameLine1 => '당신의 영웅적인 업적이';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingHallOfFameLine2 => '명예의 전당에 기록됩니다';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingHallOfFameButton => '명예의 전당';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingSkip => '건너뛰기';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingTapToSkip => '탭하여 건너뛰기';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get endingHoldToSpeedUp => '길게 누르면 빨리 스크롤';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get menuTitle => '메뉴';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsTitle => '옵션';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get soundTitle => '사운드';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get controlSection => '제어';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get infoSection => '정보';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsSection => '설정';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveExitSection => '저장 / 종료';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ok => '확인';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeButton => '충전';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get createButton => '생성';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewTitle => '미리보기';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nameTitle => '이름';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTitle => '능력치';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get raceTitle => '종족';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get classSection => '직업';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bgmLabel => 'BGM';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sfxLabel => '효과음';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hpLabel => 'HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mpLabel => 'MP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get expLabel => '경험치';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyLevelUp => '레벨 업!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifyLevel(int level) {
|
||||||
|
return '레벨 $level';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyQuestComplete => '퀘스트 완료!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyPrologueComplete => '프롤로그 완료!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifyActComplete(int number) {
|
||||||
|
return '$number막 완료!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewSpell => '새 주문!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewEquipment => '새 장비!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyBossDefeated => '보스 처치!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsTitle => '굴리기 충전';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsFree => '무료로 5회 충전하시겠습니까?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsAd => '광고를 보고 5회 충전하시겠습니까?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugTitle => '디버그';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCheatsTitle => '디버그 치트';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugToolsTitle => '디버그 도구';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugDeveloperTools => '개발자 도구';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipTask => '태스크 건너뛰기 (L+1)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipTaskDesc => '태스크 즉시 완료';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipQuest => '퀘스트 건너뛰기 (Q!)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipQuestDesc => '퀘스트 즉시 완료';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipAct => '액트 건너뛰기 (P!)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipActDesc => '액트 즉시 완료';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacter => '테스트 캐릭터 생성';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterDesc => '레벨 100 캐릭터를 명예의 전당에 등록';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterTitle => '테스트 캐릭터 생성?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterMessage =>
|
||||||
|
'현재 캐릭터가 레벨 100으로 변환되어\n명예의 전당에 등록됩니다.\n\n⚠️ 현재 세이브 파일이 삭제됩니다.\n이 작업은 되돌릴 수 없습니다.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugTurbo => '디버그: 터보 (20x)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugIapPurchased => 'IAP 구매됨';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugIapPurchasedDesc => 'ON: 유료 유저로 동작 (광고 제거)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugOfflineHours => '오프라인 시간';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugOfflineHoursDesc => '복귀 보상 테스트 (재시작 시 적용)';
|
||||||
|
|
||||||
|
@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 => '경고';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
// ignore: unused_import
|
|
||||||
import 'package:intl/intl.dart' as intl;
|
|
||||||
import 'app_localizations.dart';
|
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
|
||||||
|
|
||||||
/// The translations for Chinese (`zh`).
|
|
||||||
class L10nZh extends L10n {
|
|
||||||
L10nZh([String locale = 'zh']) : super(locale);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get appTitle => 'ASCII NEVER DIE';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get tagNoNetwork => 'No network';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get tagIdleRpg => 'Idle RPG loop';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get tagLocalSaves => 'Local saves';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get newCharacter => 'New character';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get loadSave => 'Load save';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get loadGame => 'Load Game';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get viewBuildPlan => 'View build plan';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get buildRoadmap => 'Build roadmap';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get techStack => 'Tech stack';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get cancel => 'Cancel';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get exitGame => 'Exit Game';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get saveProgressQuestion => 'Save your progress before leaving?';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get exitWithoutSaving => 'Exit without saving';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get saveAndExit => 'Save and Exit';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String progressQuestTitle(String name) {
|
|
||||||
return 'Progress Quest - $name';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get levelUp => 'Level Up';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get completeQuest => 'Complete Quest';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get completePlot => 'Complete Plot';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get characterSheet => 'Character Sheet';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get traits => 'Traits';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get stats => 'Stats';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get experience => 'Experience';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get xpNeededForNextLevel => 'XP needed for next level';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get spellBook => '技能';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get noSpellsYet => '暂无技能';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipment => 'Equipment';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get inventory => 'Inventory';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get encumbrance => 'Encumbrance';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get combatLog => '战斗日志';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get plotDevelopment => 'Plot Development';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get quests => 'Quests';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get traitName => 'Name';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get traitRace => 'Race';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get traitClass => 'Class';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get traitLevel => 'Level';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statStr => 'STR';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statCon => 'CON';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statDex => 'DEX';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statInt => 'INT';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statWis => 'WIS';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statCha => 'CHA';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statHpMax => 'HP Max';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statMpMax => 'MP Max';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipWeapon => 'Weapon';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipShield => 'Shield';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipHelm => 'Helm';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipHauberk => 'Hauberk';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipBrassairts => 'Brassairts';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipVambraces => 'Vambraces';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipGauntlets => 'Gauntlets';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipGambeson => 'Gambeson';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipCuisses => 'Cuisses';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipGreaves => 'Greaves';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipSollerets => 'Sollerets';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get gold => 'Gold';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String goldAmount(int amount) {
|
|
||||||
return 'Gold: $amount';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get prologue => 'Prologue';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String actNumber(String number) {
|
|
||||||
return 'Act $number';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get noActiveQuests => 'No active quests';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String questNumber(int number) {
|
|
||||||
return 'Quest #$number';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get welcomeMessage => 'Welcome to Progress Quest!';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get noSavedGames => 'No saved games found.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String loadError(String error) {
|
|
||||||
return 'Failed to load save file: $error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get name => 'Name';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get generateName => 'Generate Name';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get total => 'Total';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get unroll => 'Unroll';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get roll => 'Roll';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get race => 'Race';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get classTitle => 'Class';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String percentComplete(int percent) {
|
|
||||||
return '$percent% complete';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get newCharacterTitle => 'ASCII NEVER DIE - New Character';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get soldButton => 'Sold!';
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
{
|
|
||||||
"@@locale": "zh",
|
|
||||||
|
|
||||||
"appTitle": "ASCII NEVER DIE",
|
|
||||||
"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": "Progress Quest - {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",
|
|
||||||
"spellBook": "技能",
|
|
||||||
"noSpellsYet": "暂无技能",
|
|
||||||
"equipment": "Equipment",
|
|
||||||
"inventory": "Inventory",
|
|
||||||
"encumbrance": "Encumbrance",
|
|
||||||
"combatLog": "战斗日志",
|
|
||||||
"plotDevelopment": "Plot Development",
|
|
||||||
"quests": "Quests",
|
|
||||||
"traitName": "Name",
|
|
||||||
"traitRace": "Race",
|
|
||||||
"traitClass": "Class",
|
|
||||||
"traitLevel": "Level",
|
|
||||||
"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",
|
|
||||||
"gold": "Gold",
|
|
||||||
"goldAmount": "Gold: {amount}",
|
|
||||||
"prologue": "Prologue",
|
|
||||||
"actNumber": "Act {number}",
|
|
||||||
"noActiveQuests": "No active quests",
|
|
||||||
"questNumber": "Quest #{number}",
|
|
||||||
"welcomeMessage": "Welcome to Progress Quest!",
|
|
||||||
"noSavedGames": "No saved games found.",
|
|
||||||
"loadError": "Failed to load save file: {error}",
|
|
||||||
"name": "Name",
|
|
||||||
"generateName": "Generate Name",
|
|
||||||
"total": "Total",
|
|
||||||
"unroll": "Unroll",
|
|
||||||
"roll": "Roll",
|
|
||||||
"race": "Race",
|
|
||||||
"classTitle": "Class",
|
|
||||||
"percentComplete": "{percent}% complete",
|
|
||||||
"newCharacterTitle": "ASCII NEVER DIE - New Character",
|
|
||||||
"soldButton": "Sold!"
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import 'package:askiineverdie/src/app.dart';
|
import 'package:asciineverdie/src/app.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|||||||
508
lib/src/app.dart
@@ -1,24 +1,33 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:askiineverdie/data/game_text_l10n.dart' as game_l10n;
|
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||||
import 'package:askiineverdie/l10n/app_localizations.dart';
|
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||||
import 'package:askiineverdie/src/core/audio/audio_service.dart';
|
import 'package:asciineverdie/src/core/audio/audio_service.dart';
|
||||||
import 'package:askiineverdie/src/core/engine/game_mutations.dart';
|
import 'package:asciineverdie/src/core/engine/ad_service.dart';
|
||||||
import 'package:askiineverdie/src/core/engine/progress_service.dart';
|
import 'package:asciineverdie/src/core/engine/debug_settings_service.dart';
|
||||||
import 'package:askiineverdie/src/core/engine/reward_service.dart';
|
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
||||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
import 'package:asciineverdie/src/app_theme.dart';
|
||||||
import 'package:askiineverdie/src/core/model/pq_config.dart';
|
import 'package:asciineverdie/src/splash_screen.dart';
|
||||||
import 'package:askiineverdie/src/core/notification/notification_service.dart';
|
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||||
import 'package:askiineverdie/src/core/storage/save_manager.dart';
|
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
||||||
import 'package:askiineverdie/src/core/storage/save_repository.dart';
|
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
||||||
import 'package:askiineverdie/src/core/storage/settings_repository.dart';
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
import 'package:askiineverdie/src/features/front/front_screen.dart';
|
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
||||||
import 'package:askiineverdie/src/features/front/save_picker_dialog.dart';
|
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
||||||
import 'package:askiineverdie/src/features/game/game_play_screen.dart';
|
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
||||||
import 'package:askiineverdie/src/features/game/game_session_controller.dart';
|
import 'package:asciineverdie/src/core/storage/save_repository.dart';
|
||||||
import 'package:askiineverdie/src/features/game/widgets/notification_overlay.dart';
|
import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart';
|
||||||
import 'package:askiineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart';
|
import 'package:asciineverdie/src/core/storage/settings_repository.dart';
|
||||||
import 'package:askiineverdie/src/features/new_character/new_character_screen.dart';
|
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||||
|
import 'package:asciineverdie/src/features/arena/arena_screen.dart';
|
||||||
|
import 'package:asciineverdie/src/features/front/front_screen.dart';
|
||||||
|
import 'package:asciineverdie/src/features/front/save_picker_dialog.dart';
|
||||||
|
import 'package:asciineverdie/src/features/game/game_play_screen.dart';
|
||||||
|
import 'package:asciineverdie/src/features/game/game_session_controller.dart';
|
||||||
|
import 'package:asciineverdie/src/features/game/widgets/notification_overlay.dart';
|
||||||
|
import 'package:asciineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart';
|
||||||
|
import 'package:asciineverdie/src/features/new_character/new_character_screen.dart';
|
||||||
|
import 'package:asciineverdie/src/features/settings/settings_screen.dart';
|
||||||
|
|
||||||
class AskiiNeverDieApp extends StatefulWidget {
|
class AskiiNeverDieApp extends StatefulWidget {
|
||||||
const AskiiNeverDieApp({super.key});
|
const AskiiNeverDieApp({super.key});
|
||||||
@@ -27,21 +36,43 @@ class AskiiNeverDieApp extends StatefulWidget {
|
|||||||
State<AskiiNeverDieApp> createState() => _AskiiNeverDieAppState();
|
State<AskiiNeverDieApp> createState() => _AskiiNeverDieAppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
/// 저장된 게임 미리보기 정보
|
||||||
|
class SavedGamePreview {
|
||||||
|
const SavedGamePreview({
|
||||||
|
required this.characterName,
|
||||||
|
required this.level,
|
||||||
|
required this.actName,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String characterName;
|
||||||
|
final int level;
|
||||||
|
final String actName;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AskiiNeverDieAppState extends State<AskiiNeverDieApp>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
late final GameSessionController _controller;
|
late final GameSessionController _controller;
|
||||||
late final NotificationService _notificationService;
|
late final NotificationService _notificationService;
|
||||||
late final SettingsRepository _settingsRepository;
|
late final SettingsRepository _settingsRepository;
|
||||||
late final AudioService _audioService;
|
late final AudioService _audioService;
|
||||||
|
late final HallOfFameStorage _hallOfFameStorage;
|
||||||
|
final RouteObserver<ModalRoute<void>> _routeObserver =
|
||||||
|
RouteObserver<ModalRoute<void>>();
|
||||||
bool _isCheckingSave = true;
|
bool _isCheckingSave = true;
|
||||||
bool _hasSave = false;
|
bool _hasSave = false;
|
||||||
ThemeMode _themeMode = ThemeMode.system;
|
SavedGamePreview? _savedGamePreview;
|
||||||
|
HallOfFame _hallOfFame = HallOfFame.empty();
|
||||||
|
Locale? _locale; // 사용자 선택 로케일 (null이면 시스템 기본값)
|
||||||
|
bool _isAdRemovalPurchased = false;
|
||||||
|
String? _removeAdsPrice;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
const config = PqConfig();
|
const config = PqConfig();
|
||||||
final mutations = GameMutations(config);
|
final mutations = GameMutations(config);
|
||||||
final rewards = RewardService(mutations);
|
final rewards = RewardService(mutations, config);
|
||||||
|
|
||||||
_controller = GameSessionController(
|
_controller = GameSessionController(
|
||||||
progressService: ProgressService(
|
progressService: ProgressService(
|
||||||
@@ -54,72 +85,142 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
_notificationService = NotificationService();
|
_notificationService = NotificationService();
|
||||||
_settingsRepository = SettingsRepository();
|
_settingsRepository = SettingsRepository();
|
||||||
_audioService = AudioService(settingsRepository: _settingsRepository);
|
_audioService = AudioService(settingsRepository: _settingsRepository);
|
||||||
|
_hallOfFameStorage = HallOfFameStorage();
|
||||||
|
|
||||||
// 초기 설정 및 오디오 서비스 로드
|
// 초기 설정 및 오디오 서비스 로드
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
_audioService.init();
|
_audioService.init();
|
||||||
|
// IAP 서비스 초기화
|
||||||
|
_initIAP();
|
||||||
// 세이브 파일 존재 여부 확인
|
// 세이브 파일 존재 여부 확인
|
||||||
_checkForExistingSave();
|
_checkForExistingSave();
|
||||||
|
// 명예의 전당 로드
|
||||||
|
_loadHallOfFame();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 저장된 설정 불러오기
|
/// IAP 및 광고 서비스 초기화
|
||||||
Future<void> _loadSettings() async {
|
Future<void> _initIAP() async {
|
||||||
final themeMode = await _settingsRepository.loadThemeMode();
|
await IAPService.instance.initialize();
|
||||||
if (mounted) {
|
await AdService.instance.initialize();
|
||||||
setState(() => _themeMode = themeMode);
|
_updateIAPState();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 테마 모드 변경
|
/// IAP 상태 업데이트 (구매 여부, 가격)
|
||||||
void _changeThemeMode(ThemeMode mode) {
|
void _updateIAPState() {
|
||||||
setState(() => _themeMode = mode);
|
|
||||||
_settingsRepository.saveThemeMode(mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 세이브 파일 존재 여부 확인 후 자동 로드
|
|
||||||
Future<void> _checkForExistingSave() async {
|
|
||||||
final exists = await _controller.saveManager.saveExists();
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_hasSave = exists;
|
_isAdRemovalPurchased = IAPService.instance.isAdRemovalPurchased;
|
||||||
_isCheckingSave = false;
|
_removeAdsPrice = IAPService.instance.isStoreAvailable
|
||||||
|
? IAPService.instance.removeAdsPrice
|
||||||
|
: null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 명예의 전당 로드
|
||||||
|
Future<void> _loadHallOfFame() async {
|
||||||
|
final hallOfFame = await _hallOfFameStorage.load();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_hallOfFame = hallOfFame;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 저장된 설정 불러오기
|
||||||
|
Future<void> _loadSettings() async {
|
||||||
|
// 디버그 설정 먼저 초기화 (광고/IAP 시뮬레이션 설정 동기화)
|
||||||
|
await DebugSettingsService.instance.initialize();
|
||||||
|
|
||||||
|
final localeCode = await _settingsRepository.loadLocale();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
// 저장된 로케일이 있으면 적용
|
||||||
|
if (localeCode != null) {
|
||||||
|
_locale = Locale(localeCode);
|
||||||
|
game_l10n.setGameLocale(localeCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 로케일 변경
|
||||||
|
void _changeLocale(String localeCode) {
|
||||||
|
setState(() {
|
||||||
|
_locale = Locale(localeCode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 세이브 파일 존재 여부 확인 및 미리보기 정보 로드
|
||||||
|
Future<void> _checkForExistingSave() async {
|
||||||
|
final exists = await _controller.saveManager.saveExists();
|
||||||
|
SavedGamePreview? preview;
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
// 세이브 파일에서 미리보기 정보 추출
|
||||||
|
final (outcome, state, _, _) = await _controller.saveManager.loadState();
|
||||||
|
if (outcome.success && state != null) {
|
||||||
|
final actName = _getActName(state.progress.plotStageCount);
|
||||||
|
preview = SavedGamePreview(
|
||||||
|
characterName: state.traits.name,
|
||||||
|
level: state.traits.level,
|
||||||
|
actName: actName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_hasSave = exists;
|
||||||
|
_savedGamePreview = preview;
|
||||||
|
_isCheckingSave = false;
|
||||||
|
});
|
||||||
|
// 세이브 확인 완료 후 타이틀 BGM 재생 (앱이 포그라운드일 때만)
|
||||||
|
final lifecycleState = WidgetsBinding.instance.lifecycleState;
|
||||||
|
if (lifecycleState == AppLifecycleState.resumed) {
|
||||||
|
_audioService.playBgm('title');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// plotStageCount를 Act 이름으로 변환
|
||||||
|
String _getActName(int plotStageCount) {
|
||||||
|
return switch (plotStageCount) {
|
||||||
|
1 => 'Prologue',
|
||||||
|
2 => 'Act I',
|
||||||
|
3 => 'Act II',
|
||||||
|
4 => 'Act III',
|
||||||
|
5 => 'Act IV',
|
||||||
|
6 => 'Act V',
|
||||||
|
_ => 'Act V',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
_notificationService.dispose();
|
_notificationService.dispose();
|
||||||
_audioService.dispose();
|
_audioService.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 라이트 테마
|
@override
|
||||||
ThemeData get _lightTheme => ThemeData(
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF234361)),
|
super.didChangeAppLifecycleState(state);
|
||||||
scaffoldBackgroundColor: const Color(0xFFF4F5F7),
|
// 앱이 백그라운드로 내려가면 오디오 정지
|
||||||
useMaterial3: true,
|
if (state == AppLifecycleState.paused ||
|
||||||
);
|
state == AppLifecycleState.inactive) {
|
||||||
|
_audioService.pauseAll();
|
||||||
/// 다크 테마 (OLED 저전력 모드 - 순수 검정)
|
} else if (state == AppLifecycleState.resumed) {
|
||||||
ThemeData get _darkTheme => ThemeData(
|
_audioService.resumeAll().then((_) {
|
||||||
colorScheme: ColorScheme.dark(
|
// 복귀 후 BGM이 없고 시작 화면이면 타이틀 BGM 재생
|
||||||
surface: Colors.black,
|
if (_audioService.currentBgm == null && !_isCheckingSave) {
|
||||||
primary: const Color(0xFF4FC3F7), // 시안
|
_audioService.playBgm('title');
|
||||||
secondary: const Color(0xFFFF4081), // 마젠타
|
}
|
||||||
onSurface: Colors.white70,
|
});
|
||||||
primaryContainer: const Color(0xFF1A3A4A),
|
}
|
||||||
onPrimaryContainer: Colors.white,
|
}
|
||||||
),
|
|
||||||
scaffoldBackgroundColor: Colors.black,
|
|
||||||
useMaterial3: true,
|
|
||||||
// 카드/다이얼로그도 검정 배경 사용
|
|
||||||
cardColor: const Color(0xFF121212),
|
|
||||||
dialogTheme: const DialogThemeData(
|
|
||||||
backgroundColor: Color(0xFF121212),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -128,9 +229,9 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
localizationsDelegates: L10n.localizationsDelegates,
|
localizationsDelegates: L10n.localizationsDelegates,
|
||||||
supportedLocales: L10n.supportedLocales,
|
supportedLocales: L10n.supportedLocales,
|
||||||
theme: _lightTheme,
|
locale: _locale, // 사용자 선택 로케일 (null이면 시스템 기본값)
|
||||||
darkTheme: _darkTheme,
|
theme: buildAppTheme(),
|
||||||
themeMode: _themeMode,
|
navigatorObservers: [_routeObserver],
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
|
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
|
||||||
final locale = Localizations.localeOf(context);
|
final locale = Localizations.localeOf(context);
|
||||||
@@ -144,47 +245,51 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 홈 화면 결정: 세이브 확인 중 → 스플래시, 세이브 있음 → 자동 로드, 없음 → 프론트
|
/// 홈 화면 결정: 세이브 확인 중 → 스플래시, 그 외 → 프론트
|
||||||
Widget _buildHomeScreen() {
|
Widget _buildHomeScreen() {
|
||||||
// 세이브 확인 중이면 로딩 스플래시 표시
|
// 세이브 확인 중이면 로딩 스플래시 표시
|
||||||
if (_isCheckingSave) {
|
if (_isCheckingSave) {
|
||||||
return const _SplashScreen();
|
return const SplashScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 세이브 파일이 있으면 자동 로드 화면
|
|
||||||
if (_hasSave) {
|
|
||||||
return _AutoLoadScreen(
|
|
||||||
controller: _controller,
|
|
||||||
onLoadFailed: () {
|
|
||||||
// 로드 실패 시 프론트 화면으로
|
|
||||||
setState(() => _hasSave = false);
|
|
||||||
},
|
|
||||||
currentThemeMode: _themeMode,
|
|
||||||
onThemeModeChange: _changeThemeMode,
|
|
||||||
audioService: _audioService,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 세이브 파일이 없으면 기존 프론트 화면 (타이틀 BGM 재생)
|
|
||||||
_audioService.playBgm('title');
|
|
||||||
return FrontScreen(
|
return FrontScreen(
|
||||||
onNewCharacter: _navigateToNewCharacter,
|
onNewCharacter: _navigateToNewCharacter,
|
||||||
onLoadSave: _loadSave,
|
onLoadSave: _loadSave,
|
||||||
onHallOfFame: _navigateToHallOfFame,
|
onHallOfFame: _navigateToHallOfFame,
|
||||||
|
onLocalArena: _navigateToArena,
|
||||||
|
onSettings: _showSettings,
|
||||||
|
onPurchaseRemoveAds: _purchaseRemoveAds,
|
||||||
|
onRestorePurchase: _restorePurchase,
|
||||||
hasSaveFile: _hasSave,
|
hasSaveFile: _hasSave,
|
||||||
|
savedGamePreview: _savedGamePreview,
|
||||||
|
hallOfFameCount: _hallOfFame.count,
|
||||||
|
isAdRemovalPurchased: _isAdRemovalPurchased,
|
||||||
|
removeAdsPrice: _removeAdsPrice,
|
||||||
|
routeObserver: _routeObserver,
|
||||||
|
onRefresh: () {
|
||||||
|
_checkForExistingSave();
|
||||||
|
_loadHallOfFame();
|
||||||
|
_updateIAPState();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToNewCharacter(BuildContext context) {
|
void _navigateToNewCharacter(BuildContext context) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context)
|
||||||
MaterialPageRoute<void>(
|
.push(
|
||||||
builder: (context) => NewCharacterScreen(
|
MaterialPageRoute<void>(
|
||||||
onCharacterCreated: (initialState, {bool testMode = false}) {
|
builder: (context) => NewCharacterScreen(
|
||||||
_startGame(context, initialState, testMode: testMode);
|
onCharacterCreated: (initialState, {bool testMode = false}) {
|
||||||
},
|
_startGame(context, initialState, testMode: testMode);
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
)
|
||||||
|
.then((_) {
|
||||||
|
// 새 게임 후 돌아오면 세이브 정보 및 명예의 전당 갱신
|
||||||
|
_checkForExistingSave();
|
||||||
|
_loadHallOfFame();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadSave(BuildContext context) async {
|
Future<void> _loadSave(BuildContext context) async {
|
||||||
@@ -209,11 +314,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
|
|
||||||
if (selectedFileName == null || !context.mounted) return;
|
if (selectedFileName == null || !context.mounted) return;
|
||||||
|
|
||||||
// 선택된 파일 로드
|
// 선택된 파일 로드 (치트 모드는 저장된 상태에서 복원)
|
||||||
await _controller.loadAndStart(
|
await _controller.loadAndStart(fileName: selectedFileName);
|
||||||
fileName: selectedFileName,
|
|
||||||
cheatsEnabled: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (_controller.status == GameSessionStatus.running) {
|
if (_controller.status == GameSessionStatus.running) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -232,7 +334,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
GameState initialState, {
|
GameState initialState, {
|
||||||
bool testMode = false,
|
bool testMode = false,
|
||||||
}) async {
|
}) async {
|
||||||
await _controller.startNew(initialState, cheatsEnabled: false);
|
await _controller.startNew(initialState, cheatsEnabled: testMode);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
// NewCharacterScreen을 pop하고 GamePlayScreen으로 이동
|
// NewCharacterScreen을 pop하고 GamePlayScreen으로 이동
|
||||||
@@ -242,8 +344,6 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
controller: _controller,
|
controller: _controller,
|
||||||
audioService: _audioService,
|
audioService: _audioService,
|
||||||
forceCarouselLayout: testMode,
|
forceCarouselLayout: testMode,
|
||||||
currentThemeMode: _themeMode,
|
|
||||||
onThemeModeChange: _changeThemeMode,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -251,120 +351,110 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToGame(BuildContext context) {
|
void _navigateToGame(BuildContext context) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context)
|
||||||
MaterialPageRoute<void>(
|
.push(
|
||||||
builder: (context) => GamePlayScreen(
|
MaterialPageRoute<void>(
|
||||||
controller: _controller,
|
builder: (context) => GamePlayScreen(
|
||||||
audioService: _audioService,
|
controller: _controller,
|
||||||
currentThemeMode: _themeMode,
|
audioService: _audioService,
|
||||||
onThemeModeChange: _changeThemeMode,
|
// 디버그 모드로 저장된 게임 로드 시 캐로셀 레이아웃 강제
|
||||||
),
|
forceCarouselLayout: _controller.cheatsEnabled,
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
)
|
||||||
|
.then((_) {
|
||||||
|
// 게임에서 돌아오면 세이브 정보 및 명예의 전당 갱신
|
||||||
|
_checkForExistingSave();
|
||||||
|
_loadHallOfFame();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 10: 명예의 전당 화면으로 이동
|
/// Phase 10: 명예의 전당 화면으로 이동
|
||||||
void _navigateToHallOfFame(BuildContext context) {
|
void _navigateToHallOfFame(BuildContext context) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context)
|
||||||
MaterialPageRoute<void>(builder: (context) => const HallOfFameScreen()),
|
.push(
|
||||||
);
|
MaterialPageRoute<void>(
|
||||||
}
|
builder: (context) => const HallOfFameScreen(),
|
||||||
}
|
|
||||||
|
|
||||||
/// 스플래시 화면 (세이브 파일 확인 중)
|
|
||||||
class _SplashScreen extends StatelessWidget {
|
|
||||||
const _SplashScreen();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const Scaffold(
|
|
||||||
body: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'ASCII NEVER DIE',
|
|
||||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
CircularProgressIndicator(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 자동 로드 화면 (세이브 파일 자동 로드)
|
|
||||||
class _AutoLoadScreen extends StatefulWidget {
|
|
||||||
const _AutoLoadScreen({
|
|
||||||
required this.controller,
|
|
||||||
required this.onLoadFailed,
|
|
||||||
required this.currentThemeMode,
|
|
||||||
required this.onThemeModeChange,
|
|
||||||
this.audioService,
|
|
||||||
});
|
|
||||||
|
|
||||||
final GameSessionController controller;
|
|
||||||
final VoidCallback onLoadFailed;
|
|
||||||
final ThemeMode currentThemeMode;
|
|
||||||
final void Function(ThemeMode mode) onThemeModeChange;
|
|
||||||
final AudioService? audioService;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_AutoLoadScreen> createState() => _AutoLoadScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AutoLoadScreenState extends State<_AutoLoadScreen> {
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
// 로딩 중에도 타이틀 BGM 재생
|
|
||||||
widget.audioService?.playBgm('title');
|
|
||||||
_autoLoad();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _autoLoad() async {
|
|
||||||
await widget.controller.loadAndStart(cheatsEnabled: false);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
if (widget.controller.status == GameSessionStatus.running) {
|
|
||||||
// 로드 성공 → 게임 화면으로 교체
|
|
||||||
Navigator.of(context).pushReplacement(
|
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder: (context) => GamePlayScreen(
|
|
||||||
controller: widget.controller,
|
|
||||||
audioService: widget.audioService,
|
|
||||||
currentThemeMode: widget.currentThemeMode,
|
|
||||||
onThemeModeChange: widget.onThemeModeChange,
|
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
);
|
.then((_) {
|
||||||
} else {
|
// 명예의 전당에서 돌아오면 명예의 전당 갱신 및 타이틀 BGM 재생
|
||||||
// 로드 실패 → 프론트 화면으로 돌아가기
|
_loadHallOfFame();
|
||||||
widget.onLoadFailed();
|
_audioService.playBgm('title');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 로컬 아레나 화면으로 이동
|
||||||
|
void _navigateToArena(BuildContext context) {
|
||||||
|
Navigator.of(context)
|
||||||
|
.push(
|
||||||
|
MaterialPageRoute<void>(builder: (context) => const ArenaScreen()),
|
||||||
|
)
|
||||||
|
.then((_) {
|
||||||
|
// 아레나에서 돌아오면 명예의 전당 다시 로드 및 타이틀 BGM 재생
|
||||||
|
_loadHallOfFame();
|
||||||
|
_audioService.playBgm('title');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 설정 화면 표시 (모달 바텀시트)
|
||||||
|
void _showSettings(BuildContext context) {
|
||||||
|
SettingsScreen.show(
|
||||||
|
context,
|
||||||
|
settingsRepository: _settingsRepository,
|
||||||
|
onLocaleChange: _changeLocale,
|
||||||
|
onBgmVolumeChange: _audioService.setBgmVolume,
|
||||||
|
onSfxVolumeChange: _audioService.setSfxVolume,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 광고 제거 구매
|
||||||
|
Future<void> _purchaseRemoveAds(BuildContext context) async {
|
||||||
|
final result = await IAPService.instance.purchaseRemoveAds();
|
||||||
|
_updateIAPState();
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
switch (result) {
|
||||||
|
case IAPResult.success:
|
||||||
|
case IAPResult.debugSimulated:
|
||||||
|
_notificationService.showInfo(game_l10n.iapPurchaseSuccess);
|
||||||
|
case IAPResult.alreadyPurchased:
|
||||||
|
_notificationService.showInfo(game_l10n.iapAlreadyPurchased);
|
||||||
|
case IAPResult.cancelled:
|
||||||
|
// 취소는 무시
|
||||||
|
break;
|
||||||
|
case IAPResult.storeUnavailable:
|
||||||
|
_notificationService.showWarning(game_l10n.iapStoreUnavailable);
|
||||||
|
case IAPResult.productNotFound:
|
||||||
|
case IAPResult.failed:
|
||||||
|
_notificationService.showWarning(game_l10n.iapPurchaseFailed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
/// 구매 복원
|
||||||
Widget build(BuildContext context) {
|
Future<void> _restorePurchase(BuildContext context) async {
|
||||||
return Scaffold(
|
final result = await IAPService.instance.restorePurchases();
|
||||||
body: Center(
|
_updateIAPState();
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
if (!context.mounted) return;
|
||||||
children: [
|
|
||||||
const Text(
|
switch (result) {
|
||||||
'ASCII NEVER DIE',
|
case IAPResult.success:
|
||||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
case IAPResult.debugSimulated:
|
||||||
),
|
if (_isAdRemovalPurchased) {
|
||||||
const SizedBox(height: 16),
|
_notificationService.showInfo(game_l10n.iapRestoreSuccess);
|
||||||
const CircularProgressIndicator(),
|
} else {
|
||||||
const SizedBox(height: 16),
|
_notificationService.showInfo(game_l10n.iapRestoreFailed);
|
||||||
Text(game_l10n.uiLoading),
|
}
|
||||||
],
|
case IAPResult.storeUnavailable:
|
||||||
),
|
_notificationService.showWarning(game_l10n.iapStoreUnavailable);
|
||||||
),
|
case IAPResult.alreadyPurchased:
|
||||||
);
|
case IAPResult.cancelled:
|
||||||
|
case IAPResult.productNotFound:
|
||||||
|
case IAPResult.failed:
|
||||||
|
_notificationService.showWarning(game_l10n.iapRestoreFailed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
137
lib/src/app_theme.dart
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|
||||||
|
/// 앱 테마 (Dark Fantasy 스타일)
|
||||||
|
ThemeData buildAppTheme() => ThemeData(
|
||||||
|
colorScheme: RetroColors.darkColorScheme,
|
||||||
|
scaffoldBackgroundColor: RetroColors.deepBrown,
|
||||||
|
useMaterial3: true,
|
||||||
|
// 카드/다이얼로그 레트로 배경
|
||||||
|
cardColor: RetroColors.darkBrown,
|
||||||
|
dialogTheme: const DialogThemeData(
|
||||||
|
backgroundColor: Color(0xFF24283B),
|
||||||
|
titleTextStyle: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 15,
|
||||||
|
color: Color(0xFFE0AF68),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 앱바 레트로 스타일
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: Color(0xFF24283B),
|
||||||
|
foregroundColor: Color(0xFFC0CAF5),
|
||||||
|
titleTextStyle: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 15,
|
||||||
|
color: Color(0xFFE0AF68),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 버튼 테마 (inherit: false로 애니메이션 lerp 오류 방지)
|
||||||
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF3D4260),
|
||||||
|
foregroundColor: const Color(0xFFC0CAF5),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
inherit: false,
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFFE0AF68),
|
||||||
|
side: const BorderSide(color: Color(0xFFE0AF68), width: 2),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
inherit: false,
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFE0AF68),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textButtonTheme: TextButtonThemeData(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFFC0CAF5),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
inherit: false,
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 텍스트 테마
|
||||||
|
textTheme: const TextTheme(
|
||||||
|
headlineLarge: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 20,
|
||||||
|
color: Color(0xFFE0AF68),
|
||||||
|
),
|
||||||
|
headlineMedium: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 16,
|
||||||
|
color: Color(0xFFE0AF68),
|
||||||
|
),
|
||||||
|
headlineSmall: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 15,
|
||||||
|
color: Color(0xFFE0AF68),
|
||||||
|
),
|
||||||
|
titleLarge: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 15,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
titleMedium: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
titleSmall: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
bodyLarge: TextStyle(fontSize: 18, color: Color(0xFFC0CAF5)),
|
||||||
|
bodyMedium: TextStyle(fontSize: 17, color: Color(0xFFC0CAF5)),
|
||||||
|
bodySmall: TextStyle(fontSize: 15, color: Color(0xFFC0CAF5)),
|
||||||
|
labelLarge: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
labelMedium: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
labelSmall: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 13,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 칩 테마
|
||||||
|
chipTheme: const ChipThemeData(
|
||||||
|
backgroundColor: Color(0xFF2A2E3F),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
side: BorderSide(color: Color(0xFF545C7E)),
|
||||||
|
),
|
||||||
|
// 리스트 타일 테마
|
||||||
|
listTileTheme: const ListTileThemeData(
|
||||||
|
textColor: Color(0xFFC0CAF5),
|
||||||
|
iconColor: Color(0xFFE0AF68),
|
||||||
|
),
|
||||||
|
// 프로그레스 인디케이터
|
||||||
|
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
||||||
|
color: Color(0xFFE0AF68),
|
||||||
|
linearTrackColor: Color(0xFF3B4261),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
// 몬스터 크기 시스템
|
|
||||||
// 몬스터 레벨에 따라 ASCII 아트 크기 결정
|
|
||||||
|
|
||||||
/// 몬스터 크기 enum
|
|
||||||
/// 실제 프레임 줄 수와 일치하도록 설정
|
|
||||||
enum MonsterSize {
|
|
||||||
/// 2줄 (레벨 1-5)
|
|
||||||
tiny(2),
|
|
||||||
|
|
||||||
/// 4줄 (레벨 6-10)
|
|
||||||
small(4),
|
|
||||||
|
|
||||||
/// 6줄 (레벨 11-15)
|
|
||||||
medium(6),
|
|
||||||
|
|
||||||
/// 8줄 (레벨 16-25)
|
|
||||||
large(8),
|
|
||||||
|
|
||||||
/// 8줄 (레벨 26-35)
|
|
||||||
huge(8),
|
|
||||||
|
|
||||||
/// 8줄 (레벨 36-50)
|
|
||||||
giant(8),
|
|
||||||
|
|
||||||
/// 8줄 (레벨 51+, 보스급)
|
|
||||||
titanic(8);
|
|
||||||
|
|
||||||
const MonsterSize(this.lines);
|
|
||||||
|
|
||||||
/// 해당 크기의 줄 수
|
|
||||||
final int lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 몬스터 레벨에서 크기 결정
|
|
||||||
MonsterSize getMonsterSize(int? level) {
|
|
||||||
if (level == null || level <= 0) return MonsterSize.tiny;
|
|
||||||
|
|
||||||
if (level <= 5) return MonsterSize.tiny;
|
|
||||||
if (level <= 10) return MonsterSize.small;
|
|
||||||
if (level <= 15) return MonsterSize.medium;
|
|
||||||
if (level <= 25) return MonsterSize.large;
|
|
||||||
if (level <= 35) return MonsterSize.huge;
|
|
||||||
if (level <= 50) return MonsterSize.giant;
|
|
||||||
return MonsterSize.titanic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 몬스터 크기에 따른 세로 패딩 계산 (7줄 프레임에서 중앙 정렬)
|
|
||||||
int getMonsterVerticalPadding(MonsterSize size) {
|
|
||||||
return (7 - size.lines) ~/ 2;
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,73 @@
|
|||||||
|
import 'dart:async' show Completer;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show debugPrint, kIsWeb;
|
import 'package:flutter/foundation.dart' show debugPrint, kIsWeb;
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
|
||||||
import 'package:askiineverdie/src/core/storage/settings_repository.dart';
|
import 'package:asciineverdie/src/core/audio/sfx_channel_pool.dart';
|
||||||
|
import 'package:asciineverdie/src/core/storage/settings_repository.dart';
|
||||||
|
|
||||||
/// 게임 오디오 서비스
|
/// 게임 오디오 서비스 (싱글톤, 핫 리로드 안전)
|
||||||
///
|
///
|
||||||
/// BGM과 SFX를 관리하며, 설정과 연동하여 볼륨을 조절합니다.
|
/// BGM과 SFX를 관리하며, 설정과 연동하여 볼륨을 조절합니다.
|
||||||
/// 웹/WASM 환경에서는 제한적으로 작동할 수 있습니다.
|
/// 웹/WASM 환경에서는 제한적으로 작동할 수 있습니다.
|
||||||
|
///
|
||||||
|
/// 채널 구조:
|
||||||
|
/// - BGM: 단일 플레이어 (루프 재생)
|
||||||
|
/// - Player SFX: 플레이어 이펙트 (공격, 스킬, 아이템 등)
|
||||||
|
/// - Monster SFX: 몬스터 이펙트 (몬스터 공격 = 플레이어 피격)
|
||||||
|
///
|
||||||
|
/// 모든 플레이어 인스턴스를 static으로 관리하여 핫 리로드에서도 안전합니다.
|
||||||
class AudioService {
|
class AudioService {
|
||||||
AudioService({SettingsRepository? settingsRepository})
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
: _settingsRepository = settingsRepository ?? SettingsRepository();
|
// 싱글톤 패턴
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 싱글톤 인스턴스
|
||||||
|
static AudioService? _instance;
|
||||||
|
|
||||||
|
/// 싱글톤 인스턴스 반환
|
||||||
|
static AudioService get instance {
|
||||||
|
_instance ??= AudioService._internal(SettingsRepository());
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 팩토리 생성자 (싱글톤 반환)
|
||||||
|
factory AudioService({SettingsRepository? settingsRepository}) {
|
||||||
|
_instance ??= AudioService._internal(
|
||||||
|
settingsRepository ?? SettingsRepository(),
|
||||||
|
);
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// private 생성자
|
||||||
|
AudioService._internal(this._settingsRepository);
|
||||||
|
|
||||||
final SettingsRepository _settingsRepository;
|
final SettingsRepository _settingsRepository;
|
||||||
|
|
||||||
// BGM 플레이어
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
AudioPlayer? _bgmPlayer;
|
// static 플레이어 관리 (핫 리로드에서 유지)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// SFX 플레이어 풀 (동시 재생 지원)
|
/// static BGM 플레이어 (핫 리로드에서 유지)
|
||||||
final List<AudioPlayer> _sfxPlayers = [];
|
static AudioPlayer? _staticBgmPlayer;
|
||||||
static const int _maxSfxPlayers = 5;
|
|
||||||
|
/// static 초기화 완료 여부
|
||||||
|
static bool _staticInitialized = false;
|
||||||
|
|
||||||
|
/// static 초기화 진행 중 Completer (중복 초기화 방지)
|
||||||
|
static Completer<void>? _staticInitCompleter;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 인스턴스 변수
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// SFX 채널 풀 (채널별 분리, 완료 보장)
|
||||||
|
SfxChannelPool? _playerSfxPool;
|
||||||
|
SfxChannelPool? _monsterSfxPool;
|
||||||
|
|
||||||
|
// 채널별 풀 크기 (배속 전투에서 사운드 누락 방지)
|
||||||
|
static const int _playerPoolSize = 4;
|
||||||
|
static const int _monsterPoolSize = 4;
|
||||||
|
|
||||||
// 현재 볼륨
|
// 현재 볼륨
|
||||||
double _bgmVolume = 0.7;
|
double _bgmVolume = 0.7;
|
||||||
@@ -27,12 +76,6 @@ class AudioService {
|
|||||||
// 현재 재생 중인 BGM
|
// 현재 재생 중인 BGM
|
||||||
String? _currentBgm;
|
String? _currentBgm;
|
||||||
|
|
||||||
// 초기화 여부
|
|
||||||
bool _initialized = false;
|
|
||||||
|
|
||||||
// 초기화 실패 여부 (WASM 등에서 오디오 지원 안됨)
|
|
||||||
bool _initFailed = false;
|
|
||||||
|
|
||||||
// 웹에서 사용자 상호작용 대기 중인 BGM (자동재생 정책 대응)
|
// 웹에서 사용자 상호작용 대기 중인 BGM (자동재생 정책 대응)
|
||||||
String? _pendingBgm;
|
String? _pendingBgm;
|
||||||
|
|
||||||
@@ -42,173 +85,354 @@ class AudioService {
|
|||||||
// 오디오 일시정지 상태 (앱 백그라운드 시)
|
// 오디오 일시정지 상태 (앱 백그라운드 시)
|
||||||
bool _isPaused = false;
|
bool _isPaused = false;
|
||||||
|
|
||||||
|
// 일시정지 전 재생 중이던 BGM (복귀 시 재개용)
|
||||||
|
String? _pausedBgm;
|
||||||
|
|
||||||
|
// BGM 작업 진행 중 여부 (동시 호출 방지)
|
||||||
|
bool _isBgmBusy = false;
|
||||||
|
|
||||||
|
// 대기 중인 BGM (작업 중 새 요청이 들어온 경우)
|
||||||
|
String? _queuedBgm;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 초기화
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 초기화 완료 여부
|
||||||
|
bool get isInitialized => _staticInitialized;
|
||||||
|
|
||||||
/// 서비스 초기화
|
/// 서비스 초기화
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
if (_initialized || _initFailed) return;
|
// 이미 초기화 완료됨
|
||||||
|
if (_staticInitialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초기화 진행 중이면 완료 대기
|
||||||
|
if (_staticInitCompleter != null) {
|
||||||
|
await _staticInitCompleter!.future;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초기화 시작
|
||||||
|
final completer = Completer<void>();
|
||||||
|
_staticInitCompleter = completer;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 설정에서 볼륨 불러오기
|
// 설정에서 볼륨 불러오기
|
||||||
_bgmVolume = await _settingsRepository.loadBgmVolume();
|
_bgmVolume = await _settingsRepository.loadBgmVolume();
|
||||||
_sfxVolume = await _settingsRepository.loadSfxVolume();
|
_sfxVolume = await _settingsRepository.loadSfxVolume();
|
||||||
|
|
||||||
// BGM 플레이어 초기화
|
// BGM 플레이어 초기화 (순차적, 지연 포함)
|
||||||
_bgmPlayer = AudioPlayer();
|
await _initBgmPlayer();
|
||||||
await _bgmPlayer!.setLoopMode(LoopMode.one);
|
|
||||||
await _bgmPlayer!.setVolume(_bgmVolume);
|
|
||||||
|
|
||||||
// SFX 플레이어 풀 초기화
|
// 지연 후 SFX 풀 초기화 (순차적)
|
||||||
for (var i = 0; i < _maxSfxPlayers; i++) {
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||||
final player = AudioPlayer();
|
await _initSfxPools();
|
||||||
await player.setVolume(_sfxVolume);
|
|
||||||
_sfxPlayers.add(player);
|
|
||||||
}
|
|
||||||
|
|
||||||
_initialized = true;
|
_staticInitialized = true;
|
||||||
|
|
||||||
// 모바일/데스크톱에서는 자동재생 제한 없음
|
// 모바일/데스크톱에서는 자동재생 제한 없음
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
_userInteracted = true;
|
_userInteracted = true;
|
||||||
} else {
|
|
||||||
debugPrint('[AudioService] Initialized on Web platform');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugPrint('[AudioService] Initialized successfully');
|
||||||
|
completer.complete();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_initFailed = true;
|
debugPrint('[AudioService] Init error: $e');
|
||||||
debugPrint('[AudioService] Init failed (likely WASM): $e');
|
// 에러여도 완료 처리 (오디오 없이 게임 진행 가능)
|
||||||
|
_staticInitialized = true;
|
||||||
|
completer.complete();
|
||||||
|
} finally {
|
||||||
|
_staticInitCompleter = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BGM 재생
|
/// BGM 플레이어 초기화 (재시도 포함)
|
||||||
|
Future<void> _initBgmPlayer() async {
|
||||||
|
// 기존 플레이어가 있으면 재사용 (핫 리로드 대응)
|
||||||
|
if (_staticBgmPlayer != null) {
|
||||||
|
debugPrint('[AudioService] Reusing existing BGM player');
|
||||||
|
try {
|
||||||
|
await _staticBgmPlayer!.setVolume(_bgmVolume);
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 플레이어 생성 (재시도 포함)
|
||||||
|
const maxRetries = 3;
|
||||||
|
const baseDelay = Duration(milliseconds: 100);
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
if (attempt > 0) {
|
||||||
|
await Future<void>.delayed(baseDelay * (attempt + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
_staticBgmPlayer = AudioPlayer();
|
||||||
|
await _staticBgmPlayer!.setLoopMode(LoopMode.one);
|
||||||
|
await _staticBgmPlayer!.setVolume(_bgmVolume);
|
||||||
|
debugPrint('[AudioService] BGM player created');
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'[AudioService] BGM player attempt ${attempt + 1} failed: $e',
|
||||||
|
);
|
||||||
|
_staticBgmPlayer = null;
|
||||||
|
if (attempt == maxRetries - 1) {
|
||||||
|
debugPrint('[AudioService] BGM disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SFX 채널 풀 순차 초기화
|
||||||
|
Future<void> _initSfxPools() async {
|
||||||
|
// Player SFX 풀
|
||||||
|
_playerSfxPool = SfxChannelPool(
|
||||||
|
name: 'Player',
|
||||||
|
poolSize: _playerPoolSize,
|
||||||
|
volume: _sfxVolume,
|
||||||
|
);
|
||||||
|
await _playerSfxPool!.init();
|
||||||
|
|
||||||
|
// 지연 후 Monster SFX 풀
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
_monsterSfxPool = SfxChannelPool(
|
||||||
|
name: 'Monster',
|
||||||
|
poolSize: _monsterPoolSize,
|
||||||
|
volume: _sfxVolume,
|
||||||
|
);
|
||||||
|
await _monsterSfxPool!.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// BGM 재생
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// BGM 재생 (동시 호출 보호)
|
||||||
///
|
///
|
||||||
/// [name]은 assets/audio/bgm/ 폴더 내 파일명 (확장자 제외)
|
/// 여러 곳에서 동시에 호출되어도 마지막 요청만 처리합니다.
|
||||||
/// 예: playBgm('battle') → assets/audio/bgm/battle.mp3
|
/// 작업 중 새 요청이 들어오면 대기열에 저장하고 완료 후 재생합니다.
|
||||||
///
|
|
||||||
/// 웹에서 사용자 상호작용 없이 호출되면 대기 상태로 저장되고,
|
|
||||||
/// 다음 SFX 재생 시 함께 시작됩니다.
|
|
||||||
Future<void> playBgm(String name) async {
|
Future<void> playBgm(String name) async {
|
||||||
if (_initFailed) return; // 초기화 실패 시 무시
|
if (_isPaused) return;
|
||||||
if (_isPaused) return; // 일시정지 상태면 무시
|
if (!_staticInitialized) await init();
|
||||||
if (!_initialized) await init();
|
if (_bgmVolume == 0) return; // 볼륨 0이면 재생 안함
|
||||||
if (_initFailed || !_initialized) return;
|
if (_currentBgm == name) return;
|
||||||
if (_currentBgm == name) return; // 이미 재생 중
|
if (_staticBgmPlayer == null) return;
|
||||||
|
|
||||||
|
// 작업 중이면 대기열에 저장 (마지막 요청만 유지)
|
||||||
|
if (_isBgmBusy) {
|
||||||
|
_queuedBgm = name;
|
||||||
|
debugPrint('[AudioService] BGM $name queued');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isBgmBusy = true;
|
||||||
|
try {
|
||||||
|
await _playBgmInternal(name);
|
||||||
|
} finally {
|
||||||
|
_isBgmBusy = false;
|
||||||
|
// 대기 중인 BGM이 있으면 재생
|
||||||
|
if (_queuedBgm != null && _queuedBgm != _currentBgm) {
|
||||||
|
final queued = _queuedBgm;
|
||||||
|
_queuedBgm = null;
|
||||||
|
await playBgm(queued!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 내부 BGM 재생 (뮤텍스 내에서 호출)
|
||||||
|
Future<void> _playBgmInternal(String name) async {
|
||||||
|
final assetPath = 'assets/audio/bgm/$name.mp3';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _bgmPlayer!.setAsset('assets/audio/bgm/$name.mp3');
|
// 이전 BGM이 있을 때만 stop() 호출
|
||||||
await _bgmPlayer!.play();
|
if (_currentBgm != null) {
|
||||||
|
debugPrint('[AudioService] Stopping previous BGM: $_currentBgm');
|
||||||
|
await _staticBgmPlayer!.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[AudioService] Loading BGM: $assetPath');
|
||||||
|
await _staticBgmPlayer!.setAsset(assetPath);
|
||||||
|
|
||||||
|
debugPrint('[AudioService] Starting BGM playback');
|
||||||
|
await _staticBgmPlayer!.play();
|
||||||
|
|
||||||
_currentBgm = name;
|
_currentBgm = name;
|
||||||
_pendingBgm = null;
|
_pendingBgm = null;
|
||||||
_userInteracted = true; // 재생 성공 → 상호작용 확인됨
|
_userInteracted = true;
|
||||||
debugPrint('[AudioService] Playing BGM: $name');
|
debugPrint('[AudioService] Playing BGM: $name');
|
||||||
|
} on PlayerInterruptedException catch (e) {
|
||||||
|
// 다른 BGM 요청으로 인한 중단 - 정상적인 상황
|
||||||
|
debugPrint('[AudioService] BGM $name interrupted: ${e.message}');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 웹 자동재생 정책으로 실패 시 대기 상태로 저장
|
final errorStr = e.toString();
|
||||||
if (kIsWeb && e.toString().contains('NotAllowedError')) {
|
|
||||||
|
// "Loading interrupted"는 새 BGM 요청으로 인한 정상 중단
|
||||||
|
if (errorStr.contains('Loading interrupted') ||
|
||||||
|
errorStr.contains('abort')) {
|
||||||
|
debugPrint(
|
||||||
|
'[AudioService] BGM $name loading interrupted (new request)',
|
||||||
|
);
|
||||||
|
return; // 플레이어 재생성 불필요
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[AudioService] BGM error: $errorStr');
|
||||||
|
|
||||||
|
// macOS Operation Stopped 에러: 플레이어 재생성 후 재시도
|
||||||
|
if (errorStr.contains('Operation Stopped') ||
|
||||||
|
errorStr.contains('-11849')) {
|
||||||
|
debugPrint('[AudioService] Recreating BGM player...');
|
||||||
|
await _recreateBgmPlayer();
|
||||||
|
|
||||||
|
if (_staticBgmPlayer != null) {
|
||||||
|
try {
|
||||||
|
await _staticBgmPlayer!.setAsset(assetPath);
|
||||||
|
await _staticBgmPlayer!.play();
|
||||||
|
_currentBgm = name;
|
||||||
|
_userInteracted = true;
|
||||||
|
debugPrint('[AudioService] Playing BGM: $name (after recreate)');
|
||||||
|
return;
|
||||||
|
} catch (retryError) {
|
||||||
|
debugPrint('[AudioService] BGM retry failed: $retryError');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (kIsWeb && errorStr.contains('NotAllowedError')) {
|
||||||
_pendingBgm = name;
|
_pendingBgm = name;
|
||||||
debugPrint('[AudioService] BGM $name pending (waiting for user interaction)');
|
debugPrint('[AudioService] BGM $name pending (autoplay blocked)');
|
||||||
} else {
|
} else {
|
||||||
debugPrint('[AudioService] Failed to play BGM $name: $e');
|
debugPrint('[AudioService] BGM play failed: $e');
|
||||||
}
|
}
|
||||||
_currentBgm = null;
|
_currentBgm = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// BGM 플레이어 재생성 (에러 복구용)
|
||||||
|
Future<void> _recreateBgmPlayer() async {
|
||||||
|
try {
|
||||||
|
await _staticBgmPlayer?.dispose();
|
||||||
|
} catch (_) {}
|
||||||
|
_staticBgmPlayer = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_staticBgmPlayer = AudioPlayer();
|
||||||
|
await _staticBgmPlayer!.setLoopMode(LoopMode.one);
|
||||||
|
await _staticBgmPlayer!.setVolume(_bgmVolume);
|
||||||
|
debugPrint('[AudioService] BGM player recreated');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[AudioService] Failed to recreate BGM player: $e');
|
||||||
|
_staticBgmPlayer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// BGM 정지
|
/// BGM 정지
|
||||||
Future<void> stopBgm() async {
|
Future<void> stopBgm() async {
|
||||||
if (!_initialized) return;
|
if (_staticBgmPlayer == null) return;
|
||||||
|
try {
|
||||||
await _bgmPlayer!.stop();
|
await _staticBgmPlayer!.stop();
|
||||||
|
} catch (_) {}
|
||||||
_currentBgm = null;
|
_currentBgm = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BGM 일시정지
|
/// BGM 일시정지
|
||||||
Future<void> pauseBgm() async {
|
Future<void> pauseBgm() async {
|
||||||
if (!_initialized) return;
|
if (_staticBgmPlayer == null) return;
|
||||||
await _bgmPlayer!.pause();
|
try {
|
||||||
|
await _staticBgmPlayer!.pause();
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BGM 재개
|
/// BGM 재개
|
||||||
Future<void> resumeBgm() async {
|
Future<void> resumeBgm() async {
|
||||||
if (!_initialized) return;
|
if (_staticBgmPlayer == null || _currentBgm == null) return;
|
||||||
if (_currentBgm != null) {
|
try {
|
||||||
await _bgmPlayer!.play();
|
await _staticBgmPlayer!.play();
|
||||||
}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 전체 오디오 제어
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// 전체 오디오 일시정지 (앱 백그라운드 시)
|
/// 전체 오디오 일시정지 (앱 백그라운드 시)
|
||||||
///
|
|
||||||
/// BGM을 정지하고, 새로운 재생 요청을 무시합니다.
|
|
||||||
Future<void> pauseAll() async {
|
Future<void> pauseAll() async {
|
||||||
_isPaused = true;
|
_isPaused = true;
|
||||||
if (!_initialized) return;
|
_pausedBgm = _currentBgm; // 복귀 시 재개를 위해 저장
|
||||||
|
try {
|
||||||
// BGM 정지 및 상태 초기화
|
await _staticBgmPlayer?.stop();
|
||||||
await _bgmPlayer?.stop();
|
} catch (_) {}
|
||||||
_currentBgm = null;
|
_currentBgm = null;
|
||||||
|
debugPrint('[AudioService] All audio paused (was playing: $_pausedBgm)');
|
||||||
// 모든 SFX 정지
|
|
||||||
for (final player in _sfxPlayers) {
|
|
||||||
await player.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('[AudioService] All audio paused');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 전체 오디오 재개 (앱 포그라운드 복귀 시)
|
/// 전체 오디오 재개 (앱 포그라운드 복귀 시)
|
||||||
///
|
|
||||||
/// 일시정지 상태를 해제하고 이전 BGM을 재개합니다.
|
|
||||||
Future<void> resumeAll() async {
|
Future<void> resumeAll() async {
|
||||||
_isPaused = false;
|
_isPaused = false;
|
||||||
debugPrint('[AudioService] Audio resumed');
|
debugPrint('[AudioService] Audio resumed');
|
||||||
|
// 일시정지 전 재생 중이던 BGM 재개
|
||||||
|
if (_pausedBgm != null) {
|
||||||
|
final bgmToResume = _pausedBgm!;
|
||||||
|
_pausedBgm = null;
|
||||||
|
await playBgm(bgmToResume);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SFX 재생
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
///
|
// SFX 재생
|
||||||
/// [name]은 assets/audio/sfx/ 폴더 내 파일명 (확장자 제외)
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
/// 예: playSfx('attack') → assets/audio/sfx/attack.mp3
|
|
||||||
///
|
|
||||||
/// 웹에서 대기 중인 BGM이 있으면 함께 재생 시작합니다.
|
|
||||||
Future<void> playSfx(String name) async {
|
|
||||||
if (_initFailed) return; // 초기화 실패 시 무시
|
|
||||||
if (_isPaused) return; // 일시정지 상태면 무시
|
|
||||||
if (!_initialized) await init();
|
|
||||||
if (_initFailed || !_initialized) return;
|
|
||||||
if (_sfxVolume == 0) return; // 볼륨이 0이면 재생 안함
|
|
||||||
if (_sfxPlayers.isEmpty) return;
|
|
||||||
|
|
||||||
// 웹에서 대기 중인 BGM 재생 시도 (사용자 상호작용 발생)
|
/// 플레이어 이펙트 SFX 재생
|
||||||
|
Future<void> playPlayerSfx(String name) async {
|
||||||
|
if (_isPaused) return;
|
||||||
|
if (_sfxVolume == 0) return; // 볼륨 0이면 재생 안함
|
||||||
|
if (!_staticInitialized) await init();
|
||||||
|
_tryPlayPendingBgm();
|
||||||
|
await _playerSfxPool?.play('assets/audio/sfx/$name.mp3');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 몬스터 이펙트 SFX 재생
|
||||||
|
Future<void> playMonsterSfx(String name) async {
|
||||||
|
if (_isPaused) return;
|
||||||
|
if (_sfxVolume == 0) return; // 볼륨 0이면 재생 안함
|
||||||
|
if (!_staticInitialized) await init();
|
||||||
|
_tryPlayPendingBgm();
|
||||||
|
await _monsterSfxPool?.play('assets/audio/sfx/$name.mp3');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 웹에서 대기 중인 BGM 재생 시도
|
||||||
|
void _tryPlayPendingBgm() {
|
||||||
if (!_userInteracted && _pendingBgm != null) {
|
if (!_userInteracted && _pendingBgm != null) {
|
||||||
_userInteracted = true;
|
_userInteracted = true;
|
||||||
final pending = _pendingBgm;
|
final pending = _pendingBgm;
|
||||||
_pendingBgm = null;
|
_pendingBgm = null;
|
||||||
// BGM 재생 (비동기로 진행)
|
|
||||||
playBgm(pending!);
|
playBgm(pending!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사용 가능한 플레이어 찾기
|
|
||||||
AudioPlayer? availablePlayer;
|
|
||||||
for (final player in _sfxPlayers) {
|
|
||||||
if (!player.playing) {
|
|
||||||
availablePlayer = player;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모든 플레이어가 사용 중이면 첫 번째 플레이어 재사용
|
|
||||||
availablePlayer ??= _sfxPlayers.first;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await availablePlayer.setAsset('assets/audio/sfx/$name.mp3');
|
|
||||||
await availablePlayer.seek(Duration.zero);
|
|
||||||
await availablePlayer.play();
|
|
||||||
} catch (e) {
|
|
||||||
// 파일이 없으면 무시
|
|
||||||
debugPrint('[AudioService] Failed to play SFX $name: $e');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SFX 재생 (레거시 호환)
|
||||||
|
@Deprecated('playPlayerSfx 또는 playMonsterSfx를 사용하세요.')
|
||||||
|
Future<void> playSfx(String name) => playPlayerSfx(name);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 볼륨 제어
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// BGM 볼륨 설정 (0.0 ~ 1.0)
|
/// BGM 볼륨 설정 (0.0 ~ 1.0)
|
||||||
Future<void> setBgmVolume(double volume) async {
|
Future<void> setBgmVolume(double volume) async {
|
||||||
_bgmVolume = volume.clamp(0.0, 1.0);
|
_bgmVolume = volume.clamp(0.0, 1.0);
|
||||||
if (_initialized && _bgmPlayer != null) {
|
if (_staticBgmPlayer != null) {
|
||||||
await _bgmPlayer!.setVolume(_bgmVolume);
|
try {
|
||||||
|
// 볼륨 0이면 BGM 정지
|
||||||
|
if (_bgmVolume == 0) {
|
||||||
|
await _staticBgmPlayer!.stop();
|
||||||
|
_currentBgm = null;
|
||||||
|
} else {
|
||||||
|
await _staticBgmPlayer!.setVolume(_bgmVolume);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
await _settingsRepository.saveBgmVolume(_bgmVolume);
|
await _settingsRepository.saveBgmVolume(_bgmVolume);
|
||||||
}
|
}
|
||||||
@@ -216,11 +440,8 @@ class AudioService {
|
|||||||
/// SFX 볼륨 설정 (0.0 ~ 1.0)
|
/// SFX 볼륨 설정 (0.0 ~ 1.0)
|
||||||
Future<void> setSfxVolume(double volume) async {
|
Future<void> setSfxVolume(double volume) async {
|
||||||
_sfxVolume = volume.clamp(0.0, 1.0);
|
_sfxVolume = volume.clamp(0.0, 1.0);
|
||||||
if (_initialized) {
|
await _playerSfxPool?.setVolume(_sfxVolume);
|
||||||
for (final player in _sfxPlayers) {
|
await _monsterSfxPool?.setVolume(_sfxVolume);
|
||||||
await player.setVolume(_sfxVolume);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await _settingsRepository.saveSfxVolume(_sfxVolume);
|
await _settingsRepository.saveSfxVolume(_sfxVolume);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,14 +454,14 @@ class AudioService {
|
|||||||
/// 현재 재생 중인 BGM
|
/// 현재 재생 중인 BGM
|
||||||
String? get currentBgm => _currentBgm;
|
String? get currentBgm => _currentBgm;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 유틸리티
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// 사용자 상호작용 발생 알림 (웹 자동재생 정책 우회)
|
/// 사용자 상호작용 발생 알림 (웹 자동재생 정책 우회)
|
||||||
///
|
|
||||||
/// 버튼 클릭 등 사용자 상호작용 시 호출하면
|
|
||||||
/// 대기 중인 BGM이 재생됩니다.
|
|
||||||
Future<void> notifyUserInteraction() async {
|
Future<void> notifyUserInteraction() async {
|
||||||
if (_userInteracted) return;
|
if (_userInteracted) return;
|
||||||
_userInteracted = true;
|
_userInteracted = true;
|
||||||
|
|
||||||
if (_pendingBgm != null) {
|
if (_pendingBgm != null) {
|
||||||
final pending = _pendingBgm;
|
final pending = _pendingBgm;
|
||||||
_pendingBgm = null;
|
_pendingBgm = null;
|
||||||
@@ -250,60 +471,74 @@ class AudioService {
|
|||||||
|
|
||||||
/// 서비스 정리
|
/// 서비스 정리
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
await _bgmPlayer?.dispose();
|
try {
|
||||||
for (final player in _sfxPlayers) {
|
await _staticBgmPlayer?.dispose();
|
||||||
await player.dispose();
|
} catch (_) {}
|
||||||
}
|
_staticBgmPlayer = null;
|
||||||
_sfxPlayers.clear();
|
await _playerSfxPool?.dispose();
|
||||||
_initialized = false;
|
await _monsterSfxPool?.dispose();
|
||||||
|
_staticInitialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 모든 static 리소스 정리 (테스트용)
|
||||||
|
static void resetAll() {
|
||||||
|
try {
|
||||||
|
_staticBgmPlayer?.dispose();
|
||||||
|
} catch (_) {}
|
||||||
|
_staticBgmPlayer = null;
|
||||||
|
_staticInitialized = false;
|
||||||
|
_staticInitCompleter = null;
|
||||||
|
_instance = null;
|
||||||
|
SfxChannelPool.resetAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BGM 타입 열거형
|
/// BGM 타입 열거형
|
||||||
enum BgmType {
|
enum BgmType {
|
||||||
/// 타이틀 화면 BGM
|
|
||||||
title,
|
title,
|
||||||
|
|
||||||
/// 마을/상점 BGM
|
|
||||||
town,
|
town,
|
||||||
|
|
||||||
/// 일반 전투 BGM
|
|
||||||
battle,
|
battle,
|
||||||
|
battleAct4, // Act IV 전용 전투 BGM
|
||||||
/// 보스 전투 BGM
|
battleAct5, // Act V 전용 전투 BGM
|
||||||
boss,
|
boss,
|
||||||
|
actBoss, // Act 보스 전용 BGM
|
||||||
/// 레벨업/퀘스트 완료 팡파레
|
elite, // 엘리트 몬스터 전투 BGM
|
||||||
victory,
|
victory,
|
||||||
|
death, // 사망 BGM
|
||||||
|
actCinematic, // Act 전환 시네마틱 BGM
|
||||||
|
ending, // 엔딩 BGM
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SFX 타입 열거형
|
/// SFX 타입 열거형
|
||||||
enum SfxType {
|
enum SfxType {
|
||||||
/// 공격
|
|
||||||
attack,
|
attack,
|
||||||
|
|
||||||
/// 피격
|
|
||||||
hit,
|
hit,
|
||||||
|
|
||||||
/// 스킬 사용
|
|
||||||
skill,
|
skill,
|
||||||
|
|
||||||
/// 아이템 획득
|
|
||||||
item,
|
item,
|
||||||
|
|
||||||
/// UI 클릭
|
|
||||||
click,
|
click,
|
||||||
|
|
||||||
/// 레벨업
|
|
||||||
levelUp,
|
levelUp,
|
||||||
|
|
||||||
/// 퀘스트 완료
|
|
||||||
questComplete,
|
questComplete,
|
||||||
|
evade,
|
||||||
|
block,
|
||||||
|
parry,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BgmType을 파일명으로 변환
|
/// BgmType을 파일명으로 변환
|
||||||
extension BgmTypeExtension on BgmType {
|
extension BgmTypeExtension on BgmType {
|
||||||
String get fileName => name;
|
String get fileName => switch (this) {
|
||||||
|
BgmType.title => 'title',
|
||||||
|
BgmType.town => 'town',
|
||||||
|
BgmType.battle => 'battle',
|
||||||
|
BgmType.battleAct4 => 'battle_act4',
|
||||||
|
BgmType.battleAct5 => 'battle_act5',
|
||||||
|
BgmType.boss => 'boss',
|
||||||
|
BgmType.actBoss => 'act_boss',
|
||||||
|
BgmType.elite => 'elite',
|
||||||
|
BgmType.victory => 'victory',
|
||||||
|
BgmType.death => 'death',
|
||||||
|
BgmType.actCinematic => 'act_cinemetic', // 파일명 오타 유지
|
||||||
|
BgmType.ending => 'ending',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SfxType을 파일명으로 변환
|
/// SfxType을 파일명으로 변환
|
||||||
@@ -316,5 +551,8 @@ extension SfxTypeExtension on SfxType {
|
|||||||
SfxType.click => 'click',
|
SfxType.click => 'click',
|
||||||
SfxType.levelUp => 'level_up',
|
SfxType.levelUp => 'level_up',
|
||||||
SfxType.questComplete => 'quest_complete',
|
SfxType.questComplete => 'quest_complete',
|
||||||
|
SfxType.evade => 'evade',
|
||||||
|
SfxType.block => 'block',
|
||||||
|
SfxType.parry => 'parry',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
279
lib/src/core/audio/sfx_channel_pool.dart
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
|
||||||
|
/// SFX 채널 풀 - 사운드 완료 보장 (핫 리로드 안전)
|
||||||
|
///
|
||||||
|
/// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다.
|
||||||
|
/// 사용 가능한 플레이어가 없으면 대기열에 추가하고,
|
||||||
|
/// 재생 완료 시 대기열의 다음 사운드를 자동 재생합니다.
|
||||||
|
///
|
||||||
|
/// 플레이어 인스턴스를 static으로 관리하여 핫 리로드에서도 안전합니다.
|
||||||
|
class SfxChannelPool {
|
||||||
|
SfxChannelPool({required this.name, this.poolSize = 4, double volume = 0.8})
|
||||||
|
: _volume = volume;
|
||||||
|
|
||||||
|
/// 채널명 (디버그용)
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// 풀 크기
|
||||||
|
final int poolSize;
|
||||||
|
|
||||||
|
/// 채널 볼륨
|
||||||
|
double _volume;
|
||||||
|
|
||||||
|
/// static 플레이어 저장소 (채널명 기반, 핫 리로드에서 유지)
|
||||||
|
static final Map<String, List<AudioPlayer>> _staticPlayers = {};
|
||||||
|
|
||||||
|
/// static busy 상태 (채널명 기반)
|
||||||
|
static final Map<String, List<bool>> _staticBusy = {};
|
||||||
|
|
||||||
|
/// static 초기화 완료 여부 (채널명 기반)
|
||||||
|
static final Map<String, bool> _staticInitialized = {};
|
||||||
|
|
||||||
|
/// static 초기화 진행 중 Completer (중복 초기화 방지)
|
||||||
|
static final Map<String, Completer<void>?> _staticInitCompleters = {};
|
||||||
|
|
||||||
|
/// 대기열 (재생 대기 중인 에셋 경로)
|
||||||
|
final Queue<String> _pendingQueue = Queue();
|
||||||
|
|
||||||
|
/// 현재 볼륨
|
||||||
|
double get volume => _volume;
|
||||||
|
|
||||||
|
/// 초기화 완료 여부
|
||||||
|
bool get isInitialized => _staticInitialized[name] == true;
|
||||||
|
|
||||||
|
/// 사용 가능한 플레이어 수
|
||||||
|
int get availablePlayerCount => _staticPlayers[name]?.length ?? 0;
|
||||||
|
|
||||||
|
/// 초기화
|
||||||
|
Future<void> init() async {
|
||||||
|
// 이미 초기화 완료됨
|
||||||
|
if (_staticInitialized[name] == true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초기화 진행 중이면 완료 대기
|
||||||
|
if (_staticInitCompleters[name] != null) {
|
||||||
|
await _staticInitCompleters[name]!.future;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초기화 시작
|
||||||
|
final completer = Completer<void>();
|
||||||
|
_staticInitCompleters[name] = completer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 이 채널의 플레이어 리스트 초기화
|
||||||
|
_staticPlayers[name] ??= [];
|
||||||
|
_staticBusy[name] ??= [];
|
||||||
|
|
||||||
|
// 기존 플레이어가 있으면 재사용 (핫 리로드 대응)
|
||||||
|
if (_staticPlayers[name]!.isNotEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'[SfxChannelPool:$name] Reusing ${_staticPlayers[name]!.length} existing players',
|
||||||
|
);
|
||||||
|
_staticInitialized[name] = true;
|
||||||
|
completer.complete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 플레이어 순차적으로 생성 (지연 포함)
|
||||||
|
var successCount = 0;
|
||||||
|
for (var i = 0; i < poolSize; i++) {
|
||||||
|
final player = await _createPlayerWithRetry(i);
|
||||||
|
if (player != null) {
|
||||||
|
_staticPlayers[name]!.add(player);
|
||||||
|
_staticBusy[name]!.add(false);
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
_staticInitialized[name] = true;
|
||||||
|
debugPrint(
|
||||||
|
'[SfxChannelPool:$name] Initialized with $successCount/$poolSize players',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debugPrint(
|
||||||
|
'[SfxChannelPool:$name] All players failed - audio disabled',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
completer.complete();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SfxChannelPool:$name] Init error: $e');
|
||||||
|
completer.complete();
|
||||||
|
} finally {
|
||||||
|
_staticInitCompleters[name] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 플레이어 생성 (재시도 포함)
|
||||||
|
Future<AudioPlayer?> _createPlayerWithRetry(int index) async {
|
||||||
|
const maxRetries = 3;
|
||||||
|
const baseDelay = Duration(milliseconds: 100);
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
// 생성 전 지연 (첫 번째 시도에서도)
|
||||||
|
if (attempt > 0 || index > 0) {
|
||||||
|
await Future<void>.delayed(baseDelay * (attempt + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
final player = AudioPlayer();
|
||||||
|
await player.setVolume(_volume);
|
||||||
|
|
||||||
|
// 재생 완료 리스너 등록
|
||||||
|
player.playerStateStream.listen(
|
||||||
|
(state) {
|
||||||
|
if (state.processingState == ProcessingState.completed) {
|
||||||
|
_onPlayerComplete(_staticPlayers[name]!.indexOf(player));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (Object e) {
|
||||||
|
debugPrint('[SfxChannelPool:$name] Stream error: $e');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return player;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'[SfxChannelPool:$name] Player $index attempt ${attempt + 1} failed: $e',
|
||||||
|
);
|
||||||
|
if (attempt == maxRetries - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 사운드 재생 (완료 보장)
|
||||||
|
Future<void> play(String assetPath) async {
|
||||||
|
// 초기화 안됐으면 초기화 시도
|
||||||
|
if (!isInitialized) {
|
||||||
|
await init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 플레이어가 없으면 무시
|
||||||
|
final players = _staticPlayers[name];
|
||||||
|
if (players == null || players.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 볼륨이 0이면 재생 안함
|
||||||
|
if (_volume == 0) return;
|
||||||
|
|
||||||
|
// 사용 가능한 플레이어 찾기
|
||||||
|
final availableIndex = _findAvailablePlayer();
|
||||||
|
|
||||||
|
if (availableIndex != -1) {
|
||||||
|
// 즉시 재생
|
||||||
|
await _playOnPlayer(availableIndex, assetPath);
|
||||||
|
} else {
|
||||||
|
// 대기열에 추가 (최대 10개로 제한)
|
||||||
|
if (_pendingQueue.length < 10) {
|
||||||
|
_pendingQueue.add(assetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 볼륨 설정 (0.0 ~ 1.0)
|
||||||
|
Future<void> setVolume(double volume) async {
|
||||||
|
_volume = volume.clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
final players = _staticPlayers[name];
|
||||||
|
if (players != null) {
|
||||||
|
for (final player in players) {
|
||||||
|
try {
|
||||||
|
await player.setVolume(_volume);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 리소스 해제
|
||||||
|
Future<void> dispose() async {
|
||||||
|
final players = _staticPlayers[name];
|
||||||
|
if (players != null) {
|
||||||
|
for (final player in players) {
|
||||||
|
try {
|
||||||
|
await player.dispose();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
players.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_staticBusy[name]?.clear();
|
||||||
|
_pendingQueue.clear();
|
||||||
|
_staticInitialized[name] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 사용 가능한 플레이어 인덱스 반환 (-1: 없음)
|
||||||
|
int _findAvailablePlayer() {
|
||||||
|
final busy = _staticBusy[name];
|
||||||
|
if (busy == null) return -1;
|
||||||
|
|
||||||
|
for (var i = 0; i < busy.length; i++) {
|
||||||
|
if (!busy[i]) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 특정 플레이어에서 사운드 재생
|
||||||
|
Future<void> _playOnPlayer(int index, String assetPath) async {
|
||||||
|
final players = _staticPlayers[name];
|
||||||
|
final busy = _staticBusy[name];
|
||||||
|
|
||||||
|
if (players == null || busy == null) return;
|
||||||
|
if (index < 0 || index >= players.length) return;
|
||||||
|
|
||||||
|
final player = players[index];
|
||||||
|
busy[index] = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await player.stop();
|
||||||
|
await player.setAsset(assetPath);
|
||||||
|
await player.seek(Duration.zero);
|
||||||
|
await player.play();
|
||||||
|
} catch (e) {
|
||||||
|
busy[index] = false;
|
||||||
|
if (e.toString().contains('Unable to load asset')) {
|
||||||
|
debugPrint('[SfxChannelPool:$name] Asset not found: $assetPath');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 플레이어 재생 완료 시 호출
|
||||||
|
void _onPlayerComplete(int index) {
|
||||||
|
final busy = _staticBusy[name];
|
||||||
|
if (busy == null || index < 0 || index >= busy.length) return;
|
||||||
|
|
||||||
|
busy[index] = false;
|
||||||
|
|
||||||
|
if (_pendingQueue.isNotEmpty) {
|
||||||
|
final nextAsset = _pendingQueue.removeFirst();
|
||||||
|
_playOnPlayer(index, nextAsset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 모든 static 리소스 정리 (테스트용)
|
||||||
|
static void resetAll() {
|
||||||
|
for (final players in _staticPlayers.values) {
|
||||||
|
for (final player in players) {
|
||||||
|
try {
|
||||||
|
player.dispose();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_staticPlayers.clear();
|
||||||
|
_staticBusy.clear();
|
||||||
|
_staticInitialized.clear();
|
||||||
|
_staticInitCompleters.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// ASCII 애니메이션 4색 팔레트 (Phase 7)
|
|
||||||
///
|
|
||||||
/// 시각적 명확성을 위해 4가지 색상만 사용한다.
|
|
||||||
/// - 흰색: 오브젝트 (캐릭터, 몬스터, 아이템)
|
|
||||||
/// - 시안: 포지티브 이펙트 (힐, 버프, 레벨업, 획득)
|
|
||||||
/// - 마젠타: 네거티브 이펙트 (데미지, 디버프, 사망, 손실)
|
|
||||||
/// - 검정: 배경
|
|
||||||
class AsciiColors {
|
|
||||||
AsciiColors._();
|
|
||||||
|
|
||||||
/// 오브젝트 색상 (캐릭터, 몬스터, 아이템)
|
|
||||||
static const Color object = Colors.white;
|
|
||||||
|
|
||||||
/// 포지티브 이펙트 색상 (힐, 버프, 레벨업, 획득)
|
|
||||||
static const Color positive = Colors.cyan;
|
|
||||||
|
|
||||||
/// 네거티브 이펙트 색상 (데미지, 디버프, 사망, 손실)
|
|
||||||
static const Color negative = Color(0xFFFF00FF); // 마젠타
|
|
||||||
|
|
||||||
/// 배경 색상
|
|
||||||
static const Color background = Colors.black;
|
|
||||||
|
|
||||||
/// 상황에 따른 색상 반환
|
|
||||||
static Color forContext(AsciiColorContext context) {
|
|
||||||
return switch (context) {
|
|
||||||
AsciiColorContext.idle => object,
|
|
||||||
AsciiColorContext.attack => object,
|
|
||||||
AsciiColorContext.critical => negative,
|
|
||||||
AsciiColorContext.heal => positive,
|
|
||||||
AsciiColorContext.buff => positive,
|
|
||||||
AsciiColorContext.debuff => negative,
|
|
||||||
AsciiColorContext.levelUp => positive,
|
|
||||||
AsciiColorContext.death => negative,
|
|
||||||
AsciiColorContext.itemGain => positive,
|
|
||||||
AsciiColorContext.itemLoss => negative,
|
|
||||||
AsciiColorContext.dodge => object,
|
|
||||||
AsciiColorContext.block => object,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ASCII 애니메이션 색상 컨텍스트
|
|
||||||
enum AsciiColorContext {
|
|
||||||
/// 대기 상태
|
|
||||||
idle,
|
|
||||||
|
|
||||||
/// 일반 공격
|
|
||||||
attack,
|
|
||||||
|
|
||||||
/// 크리티컬 히트
|
|
||||||
critical,
|
|
||||||
|
|
||||||
/// 회복
|
|
||||||
heal,
|
|
||||||
|
|
||||||
/// 버프 획득
|
|
||||||
buff,
|
|
||||||
|
|
||||||
/// 디버프 적용
|
|
||||||
debuff,
|
|
||||||
|
|
||||||
/// 레벨업
|
|
||||||
levelUp,
|
|
||||||
|
|
||||||
/// 사망
|
|
||||||
death,
|
|
||||||
|
|
||||||
/// 아이템 획득
|
|
||||||
itemGain,
|
|
||||||
|
|
||||||
/// 아이템 손실
|
|
||||||
itemLoss,
|
|
||||||
|
|
||||||
/// 회피 성공
|
|
||||||
dodge,
|
|
||||||
|
|
||||||
/// 방패 방어
|
|
||||||
block,
|
|
||||||
}
|
|
||||||
263
lib/src/core/engine/act_progression_service.dart
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
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/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;
|
||||||
|
|
||||||
|
/// Act 진행 관련 로직을 처리하는 서비스
|
||||||
|
///
|
||||||
|
/// ProgressService에서 추출된 Act 완료, 보스 생성 등의 로직 담당.
|
||||||
|
class ActProgressionService {
|
||||||
|
const ActProgressionService({required this.config});
|
||||||
|
|
||||||
|
final PqConfig config;
|
||||||
|
|
||||||
|
/// Act 완료 처리
|
||||||
|
///
|
||||||
|
/// 플롯 진행, Act Boss 시네마틱 후 호출.
|
||||||
|
/// Returns gameComplete=true if Final Boss was defeated (game ends).
|
||||||
|
({GameState state, bool gameComplete}) completeAct(GameState state) {
|
||||||
|
// Act V 완료 시 (plotStageCount == 6) → 최종 보스 전투 시작
|
||||||
|
// plotStageCount: 1=Prologue, 2=Act I, 3=Act II, 4=Act III, 5=Act IV, 6=Act V
|
||||||
|
if (state.progress.plotStageCount >= 6) {
|
||||||
|
// 이미 최종 보스가 처치되었으면 게임 클리어
|
||||||
|
if (state.progress.finalBossState == FinalBossState.defeated) {
|
||||||
|
final updatedPlotHistory = [
|
||||||
|
...state.progress.plotHistory.map(
|
||||||
|
(e) => e.isComplete ? e : e.copyWith(isComplete: true),
|
||||||
|
),
|
||||||
|
const HistoryEntry(caption: '*** THE END ***', isComplete: true),
|
||||||
|
];
|
||||||
|
|
||||||
|
final updatedProgress = state.progress.copyWith(
|
||||||
|
plotHistory: updatedPlotHistory,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
state: state.copyWith(progress: updatedProgress),
|
||||||
|
gameComplete: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최종 보스가 아직 등장하지 않았으면 보스 전투 시작
|
||||||
|
if (state.progress.finalBossState == FinalBossState.notSpawned) {
|
||||||
|
final updatedProgress = state.progress.copyWith(
|
||||||
|
finalBossState: FinalBossState.fighting,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 게임은 아직 끝나지 않음 - 보스 전투 진행
|
||||||
|
return (
|
||||||
|
state: state.copyWith(progress: updatedProgress),
|
||||||
|
gameComplete: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 보스 전투 중이면 계속 진행 (게임 종료 안 함)
|
||||||
|
return (state: state, gameComplete: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
final actResult = pq_logic.completeAct(state.progress.plotStageCount);
|
||||||
|
var nextState = state;
|
||||||
|
|
||||||
|
// 보상 처리는 호출자(ProgressService)가 담당
|
||||||
|
// 여기서는 플롯 상태만 업데이트
|
||||||
|
|
||||||
|
final plotStages = nextState.progress.plotStageCount + 1;
|
||||||
|
|
||||||
|
// 플롯 히스토리 업데이트: 이전 플롯 완료 표시, 새 플롯 추가
|
||||||
|
final updatedPlotHistory = [
|
||||||
|
...nextState.progress.plotHistory.map(
|
||||||
|
(e) => e.isComplete ? e : e.copyWith(isComplete: true),
|
||||||
|
),
|
||||||
|
HistoryEntry(caption: actResult.actTitle, isComplete: false),
|
||||||
|
];
|
||||||
|
|
||||||
|
var updatedProgress = nextState.progress.copyWith(
|
||||||
|
plot: ProgressBarState(position: 0, max: actResult.plotBarMaxSeconds),
|
||||||
|
plotStageCount: plotStages,
|
||||||
|
plotHistory: updatedPlotHistory,
|
||||||
|
);
|
||||||
|
|
||||||
|
nextState = nextState.copyWith(progress: updatedProgress);
|
||||||
|
|
||||||
|
// Act I 완료 후(Prologue -> Act I) 첫 퀘스트 시작 (원본 Main.pas 로직)
|
||||||
|
// plotStages == 2는 Prologue(1) -> Act I(2) 전환을 의미
|
||||||
|
if (plotStages == 2) {
|
||||||
|
nextState = startFirstQuest(nextState);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (state: nextState, gameComplete: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Act 완료 시 적용할 보상 목록 반환
|
||||||
|
List<pq_logic.RewardKind> getActRewards(int plotStageCount) {
|
||||||
|
final actResult = pq_logic.completeAct(plotStageCount);
|
||||||
|
return actResult.rewards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 첫 퀘스트 시작 (Act I 시작 시)
|
||||||
|
GameState startFirstQuest(GameState state) {
|
||||||
|
final result = pq_logic.completeQuest(
|
||||||
|
config,
|
||||||
|
state.rng,
|
||||||
|
state.traits.level,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 퀘스트 바 초기화
|
||||||
|
final questBar = ProgressBarState(
|
||||||
|
position: 0,
|
||||||
|
max: 50 + state.rng.nextInt(100),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 첫 퀘스트 히스토리 추가
|
||||||
|
final questHistory = [
|
||||||
|
HistoryEntry(caption: result.caption, isComplete: false),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 퀘스트 몬스터 정보 저장 (Exterminate 타입용)
|
||||||
|
final questMonster = result.monsterIndex != null
|
||||||
|
? QuestMonsterInfo(
|
||||||
|
monsterData: result.monsterName!,
|
||||||
|
monsterIndex: result.monsterIndex!,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 첫 퀘스트 추가
|
||||||
|
final updatedQueue = QueueState(
|
||||||
|
entries: [
|
||||||
|
...state.queue.entries,
|
||||||
|
QueueEntry(
|
||||||
|
kind: QueueKind.task,
|
||||||
|
durationMillis: 50 + state.rng.nextInt(100),
|
||||||
|
caption: result.caption,
|
||||||
|
taskType: TaskType.neutral,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final progress = state.progress.copyWith(
|
||||||
|
quest: questBar,
|
||||||
|
questCount: 1,
|
||||||
|
questHistory: questHistory,
|
||||||
|
currentQuestMonster: questMonster,
|
||||||
|
);
|
||||||
|
|
||||||
|
return state.copyWith(progress: progress, queue: updatedQueue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Act Boss 생성 (Act 완료 시)
|
||||||
|
///
|
||||||
|
/// 보스 레벨 = min(플레이어 레벨, Act 최소 레벨)로 설정하여
|
||||||
|
/// 플레이어가 이길 수 있는 수준 보장
|
||||||
|
CombatState createActBoss(GameState state) {
|
||||||
|
final plotStage = state.progress.plotStageCount;
|
||||||
|
final actNumber = plotStage + 1;
|
||||||
|
|
||||||
|
// 보스 레벨 = min(플레이어 레벨, Act 최소 레벨)
|
||||||
|
// → 플레이어가 현재 레벨보다 높은 보스를 만나지 않도록 보장
|
||||||
|
final actMinLevel = ActMonsterLevel.forPlotStage(actNumber);
|
||||||
|
final bossLevel = math.min(state.traits.level, actMinLevel);
|
||||||
|
|
||||||
|
// Named monster 생성 (pq_logic.namedMonster 활용)
|
||||||
|
final bossName = pq_logic.namedMonster(config, state.rng, bossLevel);
|
||||||
|
|
||||||
|
final bossStats = MonsterBaseStats.forLevel(bossLevel);
|
||||||
|
|
||||||
|
// 플레이어 전투 스탯 생성
|
||||||
|
final playerCombatStats = CombatStats.fromStats(
|
||||||
|
stats: state.stats,
|
||||||
|
equipment: state.equipment,
|
||||||
|
level: state.traits.level,
|
||||||
|
monsterLevel: bossLevel,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Boss 몬스터 스탯 생성 (일반 몬스터 대비 강화)
|
||||||
|
final monsterCombatStats = MonsterCombatStats(
|
||||||
|
name: bossName,
|
||||||
|
level: bossLevel,
|
||||||
|
atk: (bossStats.atk * 1.5).round(), // Boss 보정 (1.5배)
|
||||||
|
def: (bossStats.def * 1.5).round(),
|
||||||
|
magDef: (bossStats.def * 1.8).round(), // 보스 마법 방어 (물리 대비 1.2배)
|
||||||
|
hpMax: (bossStats.hp * 2.0).round(), // HP는 2.0배 (보스다운 전투 시간)
|
||||||
|
hpCurrent: (bossStats.hp * 2.0).round(),
|
||||||
|
criRate: 0.05,
|
||||||
|
criDamage: 1.5,
|
||||||
|
evasion: 0.0,
|
||||||
|
accuracy: 0.8,
|
||||||
|
attackDelayMs: 1000,
|
||||||
|
expReward: (bossStats.exp * 2.5).round(), // 경험치 보상 증가
|
||||||
|
);
|
||||||
|
|
||||||
|
// 전투 상태 초기화
|
||||||
|
return CombatState.start(
|
||||||
|
playerStats: playerCombatStats,
|
||||||
|
monsterStats: monsterCombatStats,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 최종 보스(Glitch God) 전투 시작
|
||||||
|
///
|
||||||
|
/// Act V 플롯 완료 후 호출되며, 글리치 갓과의 전투를 설정합니다.
|
||||||
|
({ProgressState progress, QueueState queue}) startFinalBossFight(
|
||||||
|
GameState state,
|
||||||
|
ProgressState progress,
|
||||||
|
QueueState queue,
|
||||||
|
) {
|
||||||
|
final level = state.traits.level;
|
||||||
|
|
||||||
|
// Glitch God 생성 (레벨 100 최종 보스)
|
||||||
|
final glitchGod = MonsterCombatStats.glitchGod();
|
||||||
|
|
||||||
|
// 플레이어 전투 스탯 생성 (Phase 12: 보스 레벨 기반 페널티 적용)
|
||||||
|
final playerCombatStats = CombatStats.fromStats(
|
||||||
|
stats: state.stats,
|
||||||
|
equipment: state.equipment,
|
||||||
|
level: level,
|
||||||
|
monsterLevel: glitchGod.level,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 전투 상태 초기화
|
||||||
|
final combatState = CombatState.start(
|
||||||
|
playerStats: playerCombatStats,
|
||||||
|
monsterStats: glitchGod,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 전투 시간 추정 (보스 전투는 더 길게)
|
||||||
|
final combatCalculator = CombatCalculator(rng: state.rng);
|
||||||
|
final baseDuration = combatCalculator.estimateCombatDurationMs(
|
||||||
|
player: playerCombatStats,
|
||||||
|
monster: glitchGod,
|
||||||
|
);
|
||||||
|
// 최종 보스는 최소 10초, 최대 60초
|
||||||
|
final durationMillis = baseDuration.clamp(10000, 60000);
|
||||||
|
|
||||||
|
final taskResult = pq_logic.startTask(
|
||||||
|
progress,
|
||||||
|
l10n.taskFinalBoss(glitchGod.name),
|
||||||
|
durationMillis,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedProgress = taskResult.progress.copyWith(
|
||||||
|
currentTask: TaskInfo(
|
||||||
|
caption: taskResult.caption,
|
||||||
|
type: TaskType.kill,
|
||||||
|
monsterBaseName: 'Glitch God',
|
||||||
|
monsterPart: '*', // 특수 전리품
|
||||||
|
monsterLevel: glitchGod.level,
|
||||||
|
monsterGrade: MonsterGrade.boss, // 최종 보스는 항상 boss 등급
|
||||||
|
monsterSize: MonsterSize.large, // 최종 보스는 항상 대형
|
||||||
|
),
|
||||||
|
currentCombat: combatState,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (progress: updatedProgress, queue: queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
394
lib/src/core/engine/ad_service.dart
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
||||||
|
|
||||||
|
/// 광고 타입
|
||||||
|
enum AdType {
|
||||||
|
/// 부활용 리워드 광고 (30초)
|
||||||
|
rewardRevive,
|
||||||
|
|
||||||
|
/// 캐릭터 생성 되돌리기용 리워드 광고 (30초)
|
||||||
|
rewardUndo,
|
||||||
|
|
||||||
|
/// 굴리기 충전용 인터스티셜 광고 (6초)
|
||||||
|
interstitialRoll,
|
||||||
|
|
||||||
|
/// 속도업용 인터스티셜 광고 (6초)
|
||||||
|
interstitialSpeed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 광고 결과
|
||||||
|
enum AdResult {
|
||||||
|
/// 광고 시청 완료 (보상 지급)
|
||||||
|
completed,
|
||||||
|
|
||||||
|
/// 광고 시청 취소/스킵
|
||||||
|
cancelled,
|
||||||
|
|
||||||
|
/// 광고 로드 실패
|
||||||
|
failed,
|
||||||
|
|
||||||
|
/// 디버그 모드에서 광고 스킵
|
||||||
|
debugSkipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 광고 서비스
|
||||||
|
///
|
||||||
|
/// AdMob 리워드/인터스티셜 광고 로드 및 표시를 관리합니다.
|
||||||
|
/// 디버그 모드에서는 광고 ON/OFF 토글이 가능합니다.
|
||||||
|
class AdService {
|
||||||
|
AdService._();
|
||||||
|
|
||||||
|
static AdService? _instance;
|
||||||
|
|
||||||
|
/// 싱글톤 인스턴스
|
||||||
|
static AdService get instance {
|
||||||
|
_instance ??= AdService._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 광고 단위 ID
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 테스트 광고 ID (Google 공식 테스트 ID)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
static const String _testRewardedAndroid =
|
||||||
|
'ca-app-pub-3940256099942544/5224354917';
|
||||||
|
static const String _testRewardedIos =
|
||||||
|
'ca-app-pub-3940256099942544/1712485313';
|
||||||
|
static const String _testInterstitialAndroid =
|
||||||
|
'ca-app-pub-3940256099942544/1033173712';
|
||||||
|
static const String _testInterstitialIos =
|
||||||
|
'ca-app-pub-3940256099942544/4411468910';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 프로덕션 광고 ID (AdMob 콘솔에서 생성 후 교체)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
static const String _prodRewardedAndroid =
|
||||||
|
'ca-app-pub-6691216385521068/3457464395'; // Android 리워드 광고
|
||||||
|
static const String _prodRewardedIos =
|
||||||
|
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // TODO: iOS 리워드 광고 ID 교체
|
||||||
|
static const String _prodInterstitialAndroid =
|
||||||
|
'ca-app-pub-6691216385521068/1625507977'; // Android 인터스티셜 광고
|
||||||
|
static const String _prodInterstitialIos =
|
||||||
|
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // TODO: iOS 인터스티셜 광고 ID 교체
|
||||||
|
|
||||||
|
/// 리워드 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID)
|
||||||
|
String get _rewardAdUnitId {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return kReleaseMode ? _prodRewardedAndroid : _testRewardedAndroid;
|
||||||
|
} else if (Platform.isIOS) {
|
||||||
|
return kReleaseMode ? _prodRewardedIos : _testRewardedIos;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 인터스티셜 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID)
|
||||||
|
String get _interstitialAdUnitId {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return kReleaseMode ? _prodInterstitialAndroid : _testInterstitialAndroid;
|
||||||
|
} else if (Platform.isIOS) {
|
||||||
|
return kReleaseMode ? _prodInterstitialIos : _testInterstitialIos;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 상태
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
/// 로드된 리워드 광고
|
||||||
|
RewardedAd? _rewardedAd;
|
||||||
|
|
||||||
|
/// 로드된 인터스티셜 광고
|
||||||
|
InterstitialAd? _interstitialAd;
|
||||||
|
|
||||||
|
/// 리워드 광고 로딩 중 여부
|
||||||
|
bool _isLoadingRewardedAd = false;
|
||||||
|
|
||||||
|
/// 인터스티셜 광고 로딩 중 여부
|
||||||
|
bool _isLoadingInterstitialAd = false;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 초기화
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// AdMob SDK 초기화
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
// 모바일 플랫폼에서만 초기화
|
||||||
|
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||||
|
debugPrint('[AdService] Non-mobile platform, skipping initialization');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await MobileAds.instance.initialize();
|
||||||
|
_isInitialized = true;
|
||||||
|
debugPrint('[AdService] Initialized');
|
||||||
|
|
||||||
|
// 초기 광고 로드
|
||||||
|
_loadRewardedAd();
|
||||||
|
_loadInterstitialAd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 광고 스킵 판정
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 광고를 스킵할지 여부
|
||||||
|
///
|
||||||
|
/// 스킵 조건:
|
||||||
|
/// - 비모바일 플랫폼 (macOS, Windows, Linux, Web)
|
||||||
|
/// - IAP로 광고 제거 구매 완료 (디버그 시뮬레이션 포함)
|
||||||
|
bool get _shouldSkipAd {
|
||||||
|
// 웹에서는 항상 스킵
|
||||||
|
if (kIsWeb) return true;
|
||||||
|
// 비모바일 플랫폼(데스크톱)에서는 항상 스킵
|
||||||
|
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// IAP 광고 제거 구매 시 스킵 (디버그 시뮬레이션 포함)
|
||||||
|
if (IAPService.instance.isAdRemovalPurchased) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 리워드 광고
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 리워드 광고 로드
|
||||||
|
void _loadRewardedAd() {
|
||||||
|
if (_isLoadingRewardedAd || _rewardedAd != null) return;
|
||||||
|
if (!_isInitialized) return;
|
||||||
|
|
||||||
|
_isLoadingRewardedAd = true;
|
||||||
|
debugPrint('[AdService] Loading rewarded ad...');
|
||||||
|
|
||||||
|
RewardedAd.load(
|
||||||
|
adUnitId: _rewardAdUnitId,
|
||||||
|
request: const AdRequest(),
|
||||||
|
rewardedAdLoadCallback: RewardedAdLoadCallback(
|
||||||
|
onAdLoaded: (ad) {
|
||||||
|
_rewardedAd = ad;
|
||||||
|
_isLoadingRewardedAd = false;
|
||||||
|
debugPrint('[AdService] Rewarded ad loaded');
|
||||||
|
},
|
||||||
|
onAdFailedToLoad: (error) {
|
||||||
|
_isLoadingRewardedAd = false;
|
||||||
|
debugPrint(
|
||||||
|
'[AdService] Rewarded ad failed to load: ${error.message}',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 리워드 광고 준비 여부
|
||||||
|
bool get isRewardedAdReady => _rewardedAd != null || _shouldSkipAd;
|
||||||
|
|
||||||
|
/// 리워드 광고 표시
|
||||||
|
///
|
||||||
|
/// [adType] 광고 타입 (로깅용)
|
||||||
|
/// [onRewarded] 보상 지급 콜백
|
||||||
|
/// Returns: 광고 결과
|
||||||
|
Future<AdResult> showRewardedAd({
|
||||||
|
required AdType adType,
|
||||||
|
required void Function() onRewarded,
|
||||||
|
}) async {
|
||||||
|
// 디버그 모드에서 광고 스킵
|
||||||
|
if (_shouldSkipAd) {
|
||||||
|
debugPrint('[AdService] Debug: Skipping $adType ad');
|
||||||
|
onRewarded();
|
||||||
|
return AdResult.debugSkipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 광고가 로드되지 않은 경우
|
||||||
|
if (_rewardedAd == null) {
|
||||||
|
debugPrint('[AdService] Rewarded ad not ready');
|
||||||
|
_loadRewardedAd();
|
||||||
|
return AdResult.failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ad = _rewardedAd!;
|
||||||
|
_rewardedAd = null;
|
||||||
|
|
||||||
|
// Completer를 사용하여 광고 종료까지 대기
|
||||||
|
final completer = Completer<AdResult>();
|
||||||
|
var rewarded = false;
|
||||||
|
|
||||||
|
// 광고 표시 전 전체 화면 모드로 전환 (상단 UI 숨김)
|
||||||
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||||
|
|
||||||
|
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||||
|
onAdDismissedFullScreenContent: (ad) {
|
||||||
|
debugPrint('[AdService] Rewarded ad dismissed');
|
||||||
|
// 광고 종료 후 UI 복원
|
||||||
|
SystemChrome.setEnabledSystemUIMode(
|
||||||
|
SystemUiMode.manual,
|
||||||
|
overlays: SystemUiOverlay.values,
|
||||||
|
);
|
||||||
|
ad.dispose();
|
||||||
|
_loadRewardedAd(); // 다음 광고 미리 로드
|
||||||
|
// 보상 수령 여부에 따라 결과 반환
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(
|
||||||
|
rewarded ? AdResult.completed : AdResult.cancelled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||||
|
debugPrint('[AdService] Rewarded ad failed to show: ${error.message}');
|
||||||
|
// 광고 실패 시에도 UI 복원
|
||||||
|
SystemChrome.setEnabledSystemUIMode(
|
||||||
|
SystemUiMode.manual,
|
||||||
|
overlays: SystemUiOverlay.values,
|
||||||
|
);
|
||||||
|
ad.dispose();
|
||||||
|
_loadRewardedAd();
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(AdResult.failed);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await ad.show(
|
||||||
|
onUserEarnedReward: (ad, reward) {
|
||||||
|
debugPrint('[AdService] User earned reward: ${reward.amount}');
|
||||||
|
rewarded = true;
|
||||||
|
onRewarded();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 광고가 종료될 때까지 대기
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 인터스티셜 광고
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 인터스티셜 광고 로드
|
||||||
|
void _loadInterstitialAd() {
|
||||||
|
if (_isLoadingInterstitialAd || _interstitialAd != null) return;
|
||||||
|
if (!_isInitialized) return;
|
||||||
|
|
||||||
|
_isLoadingInterstitialAd = true;
|
||||||
|
debugPrint('[AdService] Loading interstitial ad...');
|
||||||
|
|
||||||
|
InterstitialAd.load(
|
||||||
|
adUnitId: _interstitialAdUnitId,
|
||||||
|
request: const AdRequest(),
|
||||||
|
adLoadCallback: InterstitialAdLoadCallback(
|
||||||
|
onAdLoaded: (ad) {
|
||||||
|
_interstitialAd = ad;
|
||||||
|
_isLoadingInterstitialAd = false;
|
||||||
|
debugPrint('[AdService] Interstitial ad loaded');
|
||||||
|
},
|
||||||
|
onAdFailedToLoad: (error) {
|
||||||
|
_isLoadingInterstitialAd = false;
|
||||||
|
debugPrint(
|
||||||
|
'[AdService] Interstitial ad failed to load: ${error.message}',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 인터스티셜 광고 준비 여부
|
||||||
|
bool get isInterstitialAdReady => _interstitialAd != null || _shouldSkipAd;
|
||||||
|
|
||||||
|
/// 인터스티셜 광고 표시
|
||||||
|
///
|
||||||
|
/// [adType] 광고 타입 (로깅용)
|
||||||
|
/// [onComplete] 광고 완료 콜백 (보상 지급)
|
||||||
|
/// Returns: 광고 결과
|
||||||
|
Future<AdResult> showInterstitialAd({
|
||||||
|
required AdType adType,
|
||||||
|
required void Function() onComplete,
|
||||||
|
}) async {
|
||||||
|
// 디버그 모드에서 광고 스킵
|
||||||
|
if (_shouldSkipAd) {
|
||||||
|
debugPrint('[AdService] Debug: Skipping $adType ad');
|
||||||
|
onComplete();
|
||||||
|
return AdResult.debugSkipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 광고가 로드되지 않은 경우
|
||||||
|
if (_interstitialAd == null) {
|
||||||
|
debugPrint('[AdService] Interstitial ad not ready');
|
||||||
|
_loadInterstitialAd();
|
||||||
|
return AdResult.failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ad = _interstitialAd!;
|
||||||
|
_interstitialAd = null;
|
||||||
|
|
||||||
|
// Completer를 사용하여 광고 종료까지 대기
|
||||||
|
final completer = Completer<AdResult>();
|
||||||
|
|
||||||
|
// 광고 표시 전 전체 화면 모드로 전환 (상단 UI 숨김)
|
||||||
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||||
|
|
||||||
|
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||||
|
onAdDismissedFullScreenContent: (ad) {
|
||||||
|
debugPrint('[AdService] Interstitial ad dismissed');
|
||||||
|
// 광고 종료 후 UI 복원
|
||||||
|
SystemChrome.setEnabledSystemUIMode(
|
||||||
|
SystemUiMode.manual,
|
||||||
|
overlays: SystemUiOverlay.values,
|
||||||
|
);
|
||||||
|
ad.dispose();
|
||||||
|
onComplete();
|
||||||
|
_loadInterstitialAd(); // 다음 광고 미리 로드
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(AdResult.completed);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||||
|
debugPrint(
|
||||||
|
'[AdService] Interstitial ad failed to show: ${error.message}',
|
||||||
|
);
|
||||||
|
// 광고 실패 시에도 UI 복원
|
||||||
|
SystemChrome.setEnabledSystemUIMode(
|
||||||
|
SystemUiMode.manual,
|
||||||
|
overlays: SystemUiOverlay.values,
|
||||||
|
);
|
||||||
|
ad.dispose();
|
||||||
|
_loadInterstitialAd();
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(AdResult.failed);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await ad.show();
|
||||||
|
|
||||||
|
// 광고가 종료될 때까지 대기
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 정리
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 리소스 해제
|
||||||
|
void dispose() {
|
||||||
|
_rewardedAd?.dispose();
|
||||||
|
_rewardedAd = null;
|
||||||
|
|
||||||
|
_interstitialAd?.dispose();
|
||||||
|
_interstitialAd = null;
|
||||||
|
|
||||||
|
debugPrint('[AdService] Disposed');
|
||||||
|
}
|
||||||
|
}
|
||||||
497
lib/src/core/engine/arena_combat_simulator.dart
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
import 'package:asciineverdie/data/skill_data.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||||
|
|
||||||
|
/// 아레나 전투 시뮬레이터
|
||||||
|
///
|
||||||
|
/// ArenaService에서 분리된 전투 시뮬레이션 로직.
|
||||||
|
/// 스킬 시스템을 포함한 턴 기반 전투를 처리한다.
|
||||||
|
class ArenaCombatSimulator {
|
||||||
|
ArenaCombatSimulator({required DeterministicRandom rng})
|
||||||
|
: _rng = rng,
|
||||||
|
_skillService = SkillService(rng: rng);
|
||||||
|
|
||||||
|
final DeterministicRandom _rng;
|
||||||
|
final SkillService _skillService;
|
||||||
|
|
||||||
|
/// 전투 시뮬레이션 (애니메이션용 스트림)
|
||||||
|
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) async* {
|
||||||
|
final challengerStats = match.challenger.finalStats;
|
||||||
|
final opponentStats = match.opponent.finalStats;
|
||||||
|
|
||||||
|
if (challengerStats == null || opponentStats == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final calculator = CombatCalculator(rng: _rng);
|
||||||
|
|
||||||
|
// 스킬 ID 목록 로드
|
||||||
|
var challengerSkillIds = _getSkillIdsFromEntry(match.challenger);
|
||||||
|
var opponentSkillIds = _getSkillIdsFromEntry(match.opponent);
|
||||||
|
if (challengerSkillIds.isEmpty) {
|
||||||
|
challengerSkillIds = SkillData.defaultSkillIds;
|
||||||
|
}
|
||||||
|
if (opponentSkillIds.isEmpty) {
|
||||||
|
opponentSkillIds = SkillData.defaultSkillIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스킬 시스템 상태 초기화
|
||||||
|
var challengerSkillSystem = SkillSystemState.empty();
|
||||||
|
var opponentSkillSystem = SkillSystemState.empty();
|
||||||
|
|
||||||
|
// DOT 및 디버프 추적
|
||||||
|
var challengerDoTs = <DotEffect>[];
|
||||||
|
var opponentDoTs = <DotEffect>[];
|
||||||
|
var challengerDebuffs = <ActiveBuff>[];
|
||||||
|
var opponentDebuffs = <ActiveBuff>[];
|
||||||
|
|
||||||
|
var playerCombatStats = challengerStats.copyWith(
|
||||||
|
hpCurrent: challengerStats.hpMax,
|
||||||
|
mpCurrent: challengerStats.mpMax,
|
||||||
|
);
|
||||||
|
|
||||||
|
var opponentCombatStats = opponentStats.copyWith(
|
||||||
|
hpCurrent: opponentStats.hpMax,
|
||||||
|
mpCurrent: opponentStats.mpMax,
|
||||||
|
);
|
||||||
|
|
||||||
|
int playerAccum = 0;
|
||||||
|
int opponentAccum = 0;
|
||||||
|
int elapsedMs = 0;
|
||||||
|
const tickMs = 200;
|
||||||
|
int turns = 0;
|
||||||
|
|
||||||
|
// 초기 상태 전송
|
||||||
|
yield ArenaCombatTurn(
|
||||||
|
challengerHp: playerCombatStats.hpCurrent,
|
||||||
|
opponentHp: opponentCombatStats.hpCurrent,
|
||||||
|
challengerHpMax: playerCombatStats.hpMax,
|
||||||
|
opponentHpMax: opponentCombatStats.hpMax,
|
||||||
|
challengerMp: playerCombatStats.mpCurrent,
|
||||||
|
opponentMp: opponentCombatStats.mpCurrent,
|
||||||
|
challengerMpMax: playerCombatStats.mpMax,
|
||||||
|
opponentMpMax: opponentCombatStats.mpMax,
|
||||||
|
);
|
||||||
|
|
||||||
|
while (playerCombatStats.isAlive && opponentCombatStats.hpCurrent > 0) {
|
||||||
|
playerAccum += tickMs;
|
||||||
|
opponentAccum += tickMs;
|
||||||
|
elapsedMs += tickMs;
|
||||||
|
|
||||||
|
// 스킬 시스템 시간 업데이트
|
||||||
|
challengerSkillSystem = challengerSkillSystem.copyWith(
|
||||||
|
elapsedMs: elapsedMs,
|
||||||
|
);
|
||||||
|
opponentSkillSystem = opponentSkillSystem.copyWith(elapsedMs: elapsedMs);
|
||||||
|
|
||||||
|
int? challengerDamage;
|
||||||
|
int? opponentDamage;
|
||||||
|
bool isChallengerCritical = false;
|
||||||
|
bool isOpponentCritical = false;
|
||||||
|
bool isChallengerEvaded = false;
|
||||||
|
bool isOpponentEvaded = false;
|
||||||
|
bool isChallengerBlocked = false;
|
||||||
|
bool isOpponentBlocked = false;
|
||||||
|
String? challengerSkillUsed;
|
||||||
|
String? opponentSkillUsed;
|
||||||
|
int? challengerHealAmount;
|
||||||
|
int? opponentHealAmount;
|
||||||
|
|
||||||
|
// DOT 틱 처리
|
||||||
|
final dotResult = _processDotTicks(
|
||||||
|
challengerDoTs: challengerDoTs,
|
||||||
|
opponentDoTs: opponentDoTs,
|
||||||
|
playerStats: playerCombatStats,
|
||||||
|
opponentStats: opponentCombatStats,
|
||||||
|
tickMs: tickMs,
|
||||||
|
);
|
||||||
|
challengerDoTs = dotResult.challengerDoTs;
|
||||||
|
opponentDoTs = dotResult.opponentDoTs;
|
||||||
|
playerCombatStats = dotResult.playerStats;
|
||||||
|
opponentCombatStats = dotResult.opponentStats;
|
||||||
|
|
||||||
|
// 만료된 디버프 정리
|
||||||
|
challengerDebuffs = challengerDebuffs
|
||||||
|
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
||||||
|
.toList();
|
||||||
|
opponentDebuffs = opponentDebuffs
|
||||||
|
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// 도전자 턴
|
||||||
|
if (playerAccum >= playerCombatStats.attackDelayMs) {
|
||||||
|
playerAccum = 0;
|
||||||
|
|
||||||
|
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||||
|
opponentCombatStats,
|
||||||
|
match.opponent.characterName,
|
||||||
|
);
|
||||||
|
|
||||||
|
final turnResult = _processCharacterTurn(
|
||||||
|
player: playerCombatStats,
|
||||||
|
target: opponentCombatStats,
|
||||||
|
targetMonster: opponentMonsterStats,
|
||||||
|
targetName: match.opponent.characterName,
|
||||||
|
entry: match.challenger,
|
||||||
|
skillIds: challengerSkillIds,
|
||||||
|
skillSystem: challengerSkillSystem,
|
||||||
|
activeDoTs: challengerDoTs,
|
||||||
|
activeDebuffs: opponentDebuffs,
|
||||||
|
calculator: calculator,
|
||||||
|
elapsedMs: elapsedMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
playerCombatStats = turnResult.player;
|
||||||
|
opponentCombatStats = turnResult.target;
|
||||||
|
challengerSkillSystem = turnResult.skillSystem;
|
||||||
|
challengerDoTs = turnResult.activeDoTs;
|
||||||
|
opponentDebuffs = turnResult.targetDebuffs;
|
||||||
|
challengerDamage = turnResult.damage;
|
||||||
|
isChallengerCritical = turnResult.isCritical;
|
||||||
|
isOpponentEvaded = turnResult.isTargetEvaded;
|
||||||
|
challengerSkillUsed = turnResult.skillUsed;
|
||||||
|
challengerHealAmount = turnResult.healAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상대 턴
|
||||||
|
if (opponentCombatStats.hpCurrent > 0 &&
|
||||||
|
opponentAccum >= opponentCombatStats.attackDelayMs) {
|
||||||
|
opponentAccum = 0;
|
||||||
|
|
||||||
|
var challengerMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||||
|
playerCombatStats,
|
||||||
|
match.challenger.characterName,
|
||||||
|
);
|
||||||
|
|
||||||
|
final turnResult = _processCharacterTurn(
|
||||||
|
player: opponentCombatStats,
|
||||||
|
target: playerCombatStats,
|
||||||
|
targetMonster: challengerMonsterStats,
|
||||||
|
targetName: match.challenger.characterName,
|
||||||
|
entry: match.opponent,
|
||||||
|
skillIds: opponentSkillIds,
|
||||||
|
skillSystem: opponentSkillSystem,
|
||||||
|
activeDoTs: opponentDoTs,
|
||||||
|
activeDebuffs: challengerDebuffs,
|
||||||
|
calculator: calculator,
|
||||||
|
elapsedMs: elapsedMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
opponentCombatStats = turnResult.player;
|
||||||
|
playerCombatStats = turnResult.target;
|
||||||
|
opponentSkillSystem = turnResult.skillSystem;
|
||||||
|
opponentDoTs = turnResult.activeDoTs;
|
||||||
|
challengerDebuffs = turnResult.targetDebuffs;
|
||||||
|
opponentDamage = turnResult.damage;
|
||||||
|
isOpponentCritical = turnResult.isCritical;
|
||||||
|
isChallengerEvaded = turnResult.isTargetEvaded;
|
||||||
|
isChallengerBlocked = turnResult.isTargetBlocked;
|
||||||
|
opponentSkillUsed = turnResult.skillUsed;
|
||||||
|
opponentHealAmount = turnResult.healAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 액션이 발생했을 때만 턴 전송
|
||||||
|
final hasAction =
|
||||||
|
challengerDamage != null ||
|
||||||
|
opponentDamage != null ||
|
||||||
|
challengerHealAmount != null ||
|
||||||
|
opponentHealAmount != null ||
|
||||||
|
challengerSkillUsed != null ||
|
||||||
|
opponentSkillUsed != null;
|
||||||
|
|
||||||
|
if (hasAction) {
|
||||||
|
turns++;
|
||||||
|
yield ArenaCombatTurn(
|
||||||
|
challengerDamage: challengerDamage,
|
||||||
|
opponentDamage: opponentDamage,
|
||||||
|
challengerHp: playerCombatStats.hpCurrent,
|
||||||
|
opponentHp: opponentCombatStats.hpCurrent,
|
||||||
|
challengerHpMax: playerCombatStats.hpMax,
|
||||||
|
opponentHpMax: opponentCombatStats.hpMax,
|
||||||
|
challengerMp: playerCombatStats.mpCurrent,
|
||||||
|
opponentMp: opponentCombatStats.mpCurrent,
|
||||||
|
challengerMpMax: playerCombatStats.mpMax,
|
||||||
|
opponentMpMax: opponentCombatStats.mpMax,
|
||||||
|
isChallengerCritical: isChallengerCritical,
|
||||||
|
isOpponentCritical: isOpponentCritical,
|
||||||
|
isChallengerEvaded: isChallengerEvaded,
|
||||||
|
isOpponentEvaded: isOpponentEvaded,
|
||||||
|
isChallengerBlocked: isChallengerBlocked,
|
||||||
|
isOpponentBlocked: isOpponentBlocked,
|
||||||
|
challengerSkillUsed: challengerSkillUsed,
|
||||||
|
opponentSkillUsed: opponentSkillUsed,
|
||||||
|
challengerHealAmount: challengerHealAmount,
|
||||||
|
opponentHealAmount: opponentHealAmount,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turns > 1000) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DOT 틱 처리 (양측)
|
||||||
|
({
|
||||||
|
List<DotEffect> challengerDoTs,
|
||||||
|
List<DotEffect> opponentDoTs,
|
||||||
|
CombatStats playerStats,
|
||||||
|
CombatStats opponentStats,
|
||||||
|
})
|
||||||
|
_processDotTicks({
|
||||||
|
required List<DotEffect> challengerDoTs,
|
||||||
|
required List<DotEffect> opponentDoTs,
|
||||||
|
required CombatStats playerStats,
|
||||||
|
required CombatStats opponentStats,
|
||||||
|
required int tickMs,
|
||||||
|
}) {
|
||||||
|
var updatedPlayerStats = playerStats;
|
||||||
|
var updatedOpponentStats = opponentStats;
|
||||||
|
|
||||||
|
// 도전자 -> 상대에게 적용된 DOT
|
||||||
|
var dotDamageToOpponent = 0;
|
||||||
|
final updatedChallengerDoTs = <DotEffect>[];
|
||||||
|
for (final dot in challengerDoTs) {
|
||||||
|
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
||||||
|
if (ticksTriggered > 0) {
|
||||||
|
dotDamageToOpponent += dot.damagePerTick * ticksTriggered;
|
||||||
|
}
|
||||||
|
if (updatedDot.isActive) updatedChallengerDoTs.add(updatedDot);
|
||||||
|
}
|
||||||
|
if (dotDamageToOpponent > 0 && updatedOpponentStats.hpCurrent > 0) {
|
||||||
|
updatedOpponentStats = updatedOpponentStats.copyWith(
|
||||||
|
hpCurrent: (updatedOpponentStats.hpCurrent - dotDamageToOpponent).clamp(
|
||||||
|
0,
|
||||||
|
updatedOpponentStats.hpMax,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상대 -> 도전자에게 적용된 DOT
|
||||||
|
var dotDamageToChallenger = 0;
|
||||||
|
final updatedOpponentDoTs = <DotEffect>[];
|
||||||
|
for (final dot in opponentDoTs) {
|
||||||
|
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
||||||
|
if (ticksTriggered > 0) {
|
||||||
|
dotDamageToChallenger += dot.damagePerTick * ticksTriggered;
|
||||||
|
}
|
||||||
|
if (updatedDot.isActive) updatedOpponentDoTs.add(updatedDot);
|
||||||
|
}
|
||||||
|
if (dotDamageToChallenger > 0 && updatedPlayerStats.isAlive) {
|
||||||
|
updatedPlayerStats = updatedPlayerStats.copyWith(
|
||||||
|
hpCurrent: (updatedPlayerStats.hpCurrent - dotDamageToChallenger).clamp(
|
||||||
|
0,
|
||||||
|
updatedPlayerStats.hpMax,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
challengerDoTs: updatedChallengerDoTs,
|
||||||
|
opponentDoTs: updatedOpponentDoTs,
|
||||||
|
playerStats: updatedPlayerStats,
|
||||||
|
opponentStats: updatedOpponentStats,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 캐릭터 턴 처리 (도전자/상대 공통)
|
||||||
|
({
|
||||||
|
CombatStats player,
|
||||||
|
CombatStats target,
|
||||||
|
SkillSystemState skillSystem,
|
||||||
|
List<DotEffect> activeDoTs,
|
||||||
|
List<ActiveBuff> targetDebuffs,
|
||||||
|
int? damage,
|
||||||
|
bool isCritical,
|
||||||
|
bool isTargetEvaded,
|
||||||
|
bool isTargetBlocked,
|
||||||
|
String? skillUsed,
|
||||||
|
int? healAmount,
|
||||||
|
})
|
||||||
|
_processCharacterTurn({
|
||||||
|
required CombatStats player,
|
||||||
|
required CombatStats target,
|
||||||
|
required MonsterCombatStats targetMonster,
|
||||||
|
required String targetName,
|
||||||
|
required HallOfFameEntry entry,
|
||||||
|
required List<String> skillIds,
|
||||||
|
required SkillSystemState skillSystem,
|
||||||
|
required List<DotEffect> activeDoTs,
|
||||||
|
required List<ActiveBuff> activeDebuffs,
|
||||||
|
required CombatCalculator calculator,
|
||||||
|
required int elapsedMs,
|
||||||
|
}) {
|
||||||
|
int? damage;
|
||||||
|
bool isCritical = false;
|
||||||
|
bool isTargetEvaded = false;
|
||||||
|
bool isTargetBlocked = false;
|
||||||
|
String? skillUsed;
|
||||||
|
int? healAmount;
|
||||||
|
var updatedPlayer = player;
|
||||||
|
var updatedTarget = target;
|
||||||
|
var updatedSkillSystem = skillSystem;
|
||||||
|
var updatedDoTs = [...activeDoTs];
|
||||||
|
var updatedDebuffs = [...activeDebuffs];
|
||||||
|
|
||||||
|
final selectedSkill = _skillService.selectAutoSkill(
|
||||||
|
player: updatedPlayer,
|
||||||
|
monster: targetMonster,
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
availableSkillIds: skillIds,
|
||||||
|
activeDoTs: updatedDoTs,
|
||||||
|
activeDebuffs: updatedDebuffs,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||||
|
final skillRank = _getSkillRankFromEntry(entry, selectedSkill.id);
|
||||||
|
final skillResult = _skillService.useAttackSkillWithRank(
|
||||||
|
skill: selectedSkill,
|
||||||
|
player: updatedPlayer,
|
||||||
|
monster: targetMonster,
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
rank: skillRank,
|
||||||
|
);
|
||||||
|
updatedPlayer = skillResult.updatedPlayer;
|
||||||
|
updatedTarget = updatedTarget.copyWith(
|
||||||
|
hpCurrent: skillResult.updatedMonster.hpCurrent,
|
||||||
|
);
|
||||||
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||||
|
skillUsed = selectedSkill.name;
|
||||||
|
damage = skillResult.result.damage;
|
||||||
|
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||||
|
final skillResult = _skillService.useDotSkill(
|
||||||
|
skill: selectedSkill,
|
||||||
|
player: updatedPlayer,
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
playerInt: updatedPlayer.atk ~/ 10,
|
||||||
|
playerWis: updatedPlayer.def ~/ 10,
|
||||||
|
);
|
||||||
|
updatedPlayer = skillResult.updatedPlayer;
|
||||||
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||||
|
if (skillResult.dotEffect != null) {
|
||||||
|
updatedDoTs.add(skillResult.dotEffect!);
|
||||||
|
}
|
||||||
|
skillUsed = selectedSkill.name;
|
||||||
|
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||||
|
final skillResult = _skillService.useHealSkill(
|
||||||
|
skill: selectedSkill,
|
||||||
|
player: updatedPlayer,
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
);
|
||||||
|
updatedPlayer = skillResult.updatedPlayer;
|
||||||
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||||
|
skillUsed = selectedSkill.name;
|
||||||
|
healAmount = skillResult.result.healedAmount;
|
||||||
|
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||||
|
final skillResult = _skillService.useBuffSkill(
|
||||||
|
skill: selectedSkill,
|
||||||
|
player: updatedPlayer,
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
);
|
||||||
|
updatedPlayer = skillResult.updatedPlayer;
|
||||||
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||||
|
skillUsed = selectedSkill.name;
|
||||||
|
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||||
|
final skillResult = _skillService.useDebuffSkill(
|
||||||
|
skill: selectedSkill,
|
||||||
|
player: updatedPlayer,
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
currentDebuffs: updatedDebuffs,
|
||||||
|
);
|
||||||
|
updatedPlayer = skillResult.updatedPlayer;
|
||||||
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||||
|
final debuffEffect = skillResult.debuffEffect;
|
||||||
|
if (debuffEffect != null) {
|
||||||
|
updatedDebuffs =
|
||||||
|
updatedDebuffs
|
||||||
|
.where((ActiveBuff d) => d.effect.id != debuffEffect.effect.id)
|
||||||
|
.toList()
|
||||||
|
..add(debuffEffect);
|
||||||
|
}
|
||||||
|
skillUsed = selectedSkill.name;
|
||||||
|
} else {
|
||||||
|
// 일반 공격
|
||||||
|
final opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||||
|
updatedTarget,
|
||||||
|
targetName,
|
||||||
|
);
|
||||||
|
final result = calculator.playerAttackMonster(
|
||||||
|
attacker: updatedPlayer,
|
||||||
|
defender: opponentMonsterStats,
|
||||||
|
);
|
||||||
|
updatedTarget = updatedTarget.copyWith(
|
||||||
|
hpCurrent: result.updatedDefender.hpCurrent,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.result.isHit) {
|
||||||
|
damage = result.result.damage;
|
||||||
|
isCritical = result.result.isCritical;
|
||||||
|
} else {
|
||||||
|
isTargetEvaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
player: updatedPlayer,
|
||||||
|
target: updatedTarget,
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
activeDoTs: updatedDoTs,
|
||||||
|
targetDebuffs: updatedDebuffs,
|
||||||
|
damage: damage,
|
||||||
|
isCritical: isCritical,
|
||||||
|
isTargetEvaded: isTargetEvaded,
|
||||||
|
isTargetBlocked: isTargetBlocked,
|
||||||
|
skillUsed: skillUsed,
|
||||||
|
healAmount: healAmount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 스킬 ID 목록 추출 (HallOfFameEntry에서)
|
||||||
|
List<String> _getSkillIdsFromEntry(HallOfFameEntry entry) {
|
||||||
|
final skillData = entry.finalSkills;
|
||||||
|
if (skillData == null || skillData.isEmpty) return [];
|
||||||
|
|
||||||
|
final skillIds = <String>[];
|
||||||
|
for (final data in skillData) {
|
||||||
|
final skillName = data['name'];
|
||||||
|
if (skillName != null) {
|
||||||
|
final skill = SkillData.getSkillBySpellName(skillName);
|
||||||
|
if (skill != null) {
|
||||||
|
skillIds.add(skill.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return skillIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 스킬 랭크 조회 (HallOfFameEntry의 finalSkills에서)
|
||||||
|
int _getSkillRankFromEntry(HallOfFameEntry entry, String skillId) {
|
||||||
|
final skill = SkillData.getSkillById(skillId);
|
||||||
|
if (skill == null) return 1;
|
||||||
|
|
||||||
|
final skillData = entry.finalSkills;
|
||||||
|
if (skillData == null || skillData.isEmpty) return 1;
|
||||||
|
|
||||||
|
for (final data in skillData) {
|
||||||
|
if (data['name'] == skill.name) {
|
||||||
|
final rankStr = data['rank'] ?? 'I';
|
||||||
|
return switch (rankStr) {
|
||||||
|
'I' => 1,
|
||||||
|
'II' => 2,
|
||||||
|
'III' => 3,
|
||||||
|
'IV' => 4,
|
||||||
|
'V' => 5,
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
308
lib/src/core/engine/arena_service.dart
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
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/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/hall_of_fame.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||||
|
|
||||||
|
/// 아레나 서비스
|
||||||
|
///
|
||||||
|
/// 로컬 아레나 대전 시스템의 핵심 로직 담당:
|
||||||
|
/// - 순위 계산 및 상대 결정
|
||||||
|
/// - 전투 실행
|
||||||
|
/// - 장비 교환
|
||||||
|
class ArenaService {
|
||||||
|
ArenaService({DeterministicRandom? rng})
|
||||||
|
: _rng = rng ?? DeterministicRandom(DateTime.now().millisecondsSinceEpoch);
|
||||||
|
|
||||||
|
final DeterministicRandom _rng;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 상대 결정
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 상대 결정 (바로 위 순위, 1위면 2위와 대결)
|
||||||
|
///
|
||||||
|
/// [hallOfFame] 명예의 전당
|
||||||
|
/// [challengerId] 도전자 캐릭터 ID
|
||||||
|
/// Returns: 상대 캐릭터 (없으면 null)
|
||||||
|
HallOfFameEntry? findOpponent(HallOfFame hallOfFame, String challengerId) {
|
||||||
|
final ranked = hallOfFame.rankedEntries;
|
||||||
|
if (ranked.length < 2) return null;
|
||||||
|
|
||||||
|
final currentRank = hallOfFame.getRank(challengerId);
|
||||||
|
if (currentRank <= 0) return null;
|
||||||
|
|
||||||
|
// 1위면 2위와 대결
|
||||||
|
if (currentRank == 1) {
|
||||||
|
return ranked[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그 외는 바로 위 순위와 대결
|
||||||
|
return ranked[currentRank - 2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 전투 실행
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 아레나 전투 실행
|
||||||
|
///
|
||||||
|
/// [match] 대전 정보
|
||||||
|
/// Returns: 대전 결과 (승패, 장비 교환 후 캐릭터)
|
||||||
|
ArenaMatchResult executeCombat(ArenaMatch match) {
|
||||||
|
final calculator = CombatCalculator(rng: _rng);
|
||||||
|
|
||||||
|
// 도전자 스탯 (풀 HP로 시작)
|
||||||
|
final challengerStats = match.challenger.finalStats;
|
||||||
|
final opponentStats = match.opponent.finalStats;
|
||||||
|
|
||||||
|
if (challengerStats == null || opponentStats == null) {
|
||||||
|
// 스탯이 없으면 도전자 패배 처리
|
||||||
|
return ArenaMatchResult(
|
||||||
|
match: match,
|
||||||
|
isVictory: false,
|
||||||
|
turns: 0,
|
||||||
|
updatedChallenger: match.challenger,
|
||||||
|
updatedOpponent: match.opponent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 플레이어 스탯 (풀 HP로 초기화)
|
||||||
|
var playerCombatStats = challengerStats.copyWith(
|
||||||
|
hpCurrent: challengerStats.hpMax,
|
||||||
|
mpCurrent: challengerStats.mpMax,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 상대를 몬스터 형태로 변환
|
||||||
|
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||||
|
opponentStats,
|
||||||
|
match.opponent.characterName,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 전투 시뮬레이션
|
||||||
|
int turns = 0;
|
||||||
|
int playerAccum = 0;
|
||||||
|
int opponentAccum = 0;
|
||||||
|
const tickMs = 200;
|
||||||
|
|
||||||
|
while (playerCombatStats.isAlive && opponentMonsterStats.isAlive) {
|
||||||
|
playerAccum += tickMs;
|
||||||
|
opponentAccum += tickMs;
|
||||||
|
|
||||||
|
// 플레이어 공격
|
||||||
|
if (playerAccum >= playerCombatStats.attackDelayMs) {
|
||||||
|
final result = calculator.playerAttackMonster(
|
||||||
|
attacker: playerCombatStats,
|
||||||
|
defender: opponentMonsterStats,
|
||||||
|
);
|
||||||
|
opponentMonsterStats = result.updatedDefender;
|
||||||
|
playerAccum = 0;
|
||||||
|
turns++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상대 공격 (살아있을 때만)
|
||||||
|
if (opponentMonsterStats.isAlive &&
|
||||||
|
opponentAccum >= opponentMonsterStats.attackDelayMs) {
|
||||||
|
final result = calculator.monsterAttackPlayer(
|
||||||
|
attacker: opponentMonsterStats,
|
||||||
|
defender: playerCombatStats,
|
||||||
|
);
|
||||||
|
playerCombatStats = result.updatedDefender;
|
||||||
|
opponentAccum = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 무한 루프 방지
|
||||||
|
if (turns > 1000) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isVictory = playerCombatStats.isAlive;
|
||||||
|
|
||||||
|
// 장비 교환
|
||||||
|
final (updatedChallenger, updatedOpponent) = _exchangeEquipment(
|
||||||
|
match: match,
|
||||||
|
isVictory: isVictory,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ArenaMatchResult(
|
||||||
|
match: match,
|
||||||
|
isVictory: isVictory,
|
||||||
|
turns: turns,
|
||||||
|
updatedChallenger: updatedChallenger,
|
||||||
|
updatedOpponent: updatedOpponent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 시뮬레이션 결과를 기반으로 전투 결과 생성
|
||||||
|
///
|
||||||
|
/// [match] 대전 정보
|
||||||
|
/// [challengerHp] 도전자 최종 HP
|
||||||
|
/// [opponentHp] 상대 최종 HP
|
||||||
|
/// [turns] 총 턴 수
|
||||||
|
/// Returns: 대전 결과 (승패, 장비 교환 후 캐릭터)
|
||||||
|
ArenaMatchResult createResultFromSimulation({
|
||||||
|
required ArenaMatch match,
|
||||||
|
required int challengerHp,
|
||||||
|
required int opponentHp,
|
||||||
|
required int turns,
|
||||||
|
}) {
|
||||||
|
// 도전자 HP가 0보다 크면 승리
|
||||||
|
final isVictory = challengerHp > 0 && opponentHp <= 0;
|
||||||
|
|
||||||
|
// 장비 교환
|
||||||
|
final (updatedChallenger, updatedOpponent) = _exchangeEquipment(
|
||||||
|
match: match,
|
||||||
|
isVictory: isVictory,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ArenaMatchResult(
|
||||||
|
match: match,
|
||||||
|
isVictory: isVictory,
|
||||||
|
turns: turns,
|
||||||
|
updatedChallenger: updatedChallenger,
|
||||||
|
updatedOpponent: updatedOpponent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 전투 시뮬레이션 (애니메이션용 스트림)
|
||||||
|
///
|
||||||
|
/// ArenaCombatSimulator에 위임하여 턴별 전투 상황을 스트림으로 반환.
|
||||||
|
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) {
|
||||||
|
final simulator = ArenaCombatSimulator(rng: _rng);
|
||||||
|
return simulator.simulateCombat(match);
|
||||||
|
}
|
||||||
|
// ============================================================================
|
||||||
|
// AI 베팅 슬롯 선택
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// AI가 도전자에게서 약탈할 슬롯 자동 선택
|
||||||
|
///
|
||||||
|
/// 도전자의 가장 좋은 장비 슬롯 선택 (무기 제외)
|
||||||
|
EquipmentSlot selectOpponentBettingSlot(HallOfFameEntry challenger) {
|
||||||
|
final equipment = challenger.finalEquipment ?? [];
|
||||||
|
if (equipment.isEmpty) {
|
||||||
|
// 장비가 없으면 기본 슬롯 (투구)
|
||||||
|
return EquipmentSlot.helm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 무기를 제외한 장비 중 가장 높은 점수의 슬롯 선택
|
||||||
|
EquipmentSlot? bestSlot;
|
||||||
|
int bestScore = -1;
|
||||||
|
|
||||||
|
for (final item in equipment) {
|
||||||
|
// 무기는 약탈 불가
|
||||||
|
if (item.slot == EquipmentSlot.weapon) continue;
|
||||||
|
if (item.isEmpty) continue;
|
||||||
|
|
||||||
|
final score = ItemService.calculateEquipmentScore(item);
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestSlot = item.slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유효한 슬롯이 없으면 투구 선택
|
||||||
|
return bestSlot ?? EquipmentSlot.helm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 베팅 가능한 슬롯 목록 반환 (무기 제외)
|
||||||
|
List<EquipmentSlot> getBettableSlots() {
|
||||||
|
return EquipmentSlot.values
|
||||||
|
.where((slot) => slot != EquipmentSlot.weapon)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 장비 약탈
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 장비 약탈 (승자가 패자의 베팅 슬롯 장비 획득)
|
||||||
|
///
|
||||||
|
/// - 승자: 자신이 선택한 슬롯의 패자 장비 획득
|
||||||
|
/// - 패자: 해당 슬롯 장비 손실 → 기본 장비로 대체
|
||||||
|
(HallOfFameEntry, HallOfFameEntry) _exchangeEquipment({
|
||||||
|
required ArenaMatch match,
|
||||||
|
required bool isVictory,
|
||||||
|
}) {
|
||||||
|
// 도전자 장비 목록 복사
|
||||||
|
final challengerEquipment = List<EquipmentItem>.from(
|
||||||
|
match.challenger.finalEquipment ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 상대 장비 목록 복사
|
||||||
|
final opponentEquipment = List<EquipmentItem>.from(
|
||||||
|
match.opponent.finalEquipment ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isVictory) {
|
||||||
|
// 도전자 승리: 도전자가 선택한 슬롯의 상대 장비 획득
|
||||||
|
final winnerSlot = match.challengerBettingSlot;
|
||||||
|
final lootedItem = _findItemBySlot(opponentEquipment, winnerSlot);
|
||||||
|
|
||||||
|
// 도전자: 약탈한 장비로 교체
|
||||||
|
_replaceItemInList(challengerEquipment, winnerSlot, lootedItem);
|
||||||
|
|
||||||
|
// 상대: 해당 슬롯 기본 장비로 대체
|
||||||
|
final defaultItem = _createDefaultEquipment(winnerSlot);
|
||||||
|
_replaceItemInList(opponentEquipment, winnerSlot, defaultItem);
|
||||||
|
} else {
|
||||||
|
// 상대 승리: 상대가 선택한 슬롯의 도전자 장비 획득
|
||||||
|
final winnerSlot = match.opponentBettingSlot;
|
||||||
|
final lootedItem = _findItemBySlot(challengerEquipment, winnerSlot);
|
||||||
|
|
||||||
|
// 상대: 약탈한 장비로 교체
|
||||||
|
_replaceItemInList(opponentEquipment, winnerSlot, lootedItem);
|
||||||
|
|
||||||
|
// 도전자: 해당 슬롯 기본 장비로 대체
|
||||||
|
final defaultItem = _createDefaultEquipment(winnerSlot);
|
||||||
|
_replaceItemInList(challengerEquipment, winnerSlot, defaultItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 업데이트된 엔트리 생성
|
||||||
|
final updatedChallenger = match.challenger.copyWith(
|
||||||
|
finalEquipment: challengerEquipment,
|
||||||
|
);
|
||||||
|
final updatedOpponent = match.opponent.copyWith(
|
||||||
|
finalEquipment: opponentEquipment,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (updatedChallenger, updatedOpponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 슬롯으로 장비 찾기
|
||||||
|
EquipmentItem _findItemBySlot(
|
||||||
|
List<EquipmentItem> equipment,
|
||||||
|
EquipmentSlot slot,
|
||||||
|
) {
|
||||||
|
for (final item in equipment) {
|
||||||
|
if (item.slot == slot) return item;
|
||||||
|
}
|
||||||
|
return EquipmentItem.empty(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 장비 목록에서 특정 슬롯의 아이템 교체
|
||||||
|
void _replaceItemInList(
|
||||||
|
List<EquipmentItem> equipment,
|
||||||
|
EquipmentSlot slot,
|
||||||
|
EquipmentItem newItem,
|
||||||
|
) {
|
||||||
|
for (var i = 0; i < equipment.length; i++) {
|
||||||
|
if (equipment[i].slot == slot) {
|
||||||
|
equipment[i] = newItem;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 슬롯이 없으면 추가
|
||||||
|
equipment.add(newItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 기본 장비 생성 (Common 등급)
|
||||||
|
///
|
||||||
|
/// 패자가 장비를 잃었을 때 빈 슬롯 방지용
|
||||||
|
EquipmentItem _createDefaultEquipment(EquipmentSlot slot) {
|
||||||
|
return ItemService.createDefaultEquipmentForSlot(slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
295
lib/src/core/engine/character_roll_service.dart
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/src/core/engine/ad_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
|
||||||
|
/// 캐릭터 생성 굴리기/되돌리기 서비스
|
||||||
|
///
|
||||||
|
/// 굴리기 횟수 제한과 되돌리기 기능을 관리합니다.
|
||||||
|
/// - 굴리기: 5회 (0회 시 광고 시청으로 충전)
|
||||||
|
/// - 되돌리기: 무료 유저 1회(광고), 유료 유저 3회(무료)
|
||||||
|
class CharacterRollService {
|
||||||
|
CharacterRollService._();
|
||||||
|
|
||||||
|
static CharacterRollService? _instance;
|
||||||
|
|
||||||
|
/// 싱글톤 인스턴스
|
||||||
|
static CharacterRollService get instance {
|
||||||
|
_instance ??= CharacterRollService._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 상수
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 저장 키
|
||||||
|
static const String _rollsRemainingKey = 'char_rolls_remaining';
|
||||||
|
|
||||||
|
/// 최대 굴리기 횟수
|
||||||
|
static const int maxRolls = 5;
|
||||||
|
|
||||||
|
/// 최대 되돌리기 횟수 (무료 유저)
|
||||||
|
static const int maxUndoFreeUser = 1;
|
||||||
|
|
||||||
|
/// 최대 되돌리기 횟수 (유료 유저)
|
||||||
|
static const int maxUndoPaidUser = 3;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 상태
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
/// 남은 굴리기 횟수
|
||||||
|
int _rollsRemaining = maxRolls;
|
||||||
|
|
||||||
|
/// 남은 되돌리기 횟수
|
||||||
|
int _undoRemaining = maxUndoFreeUser;
|
||||||
|
|
||||||
|
/// 되돌리기용 스탯 히스토리
|
||||||
|
final List<RollSnapshot> _rollHistory = [];
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 초기화
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 서비스 초기화
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
await _loadState();
|
||||||
|
_resetUndoForNewSession();
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
debugPrint(
|
||||||
|
'[CharacterRollService] Initialized: '
|
||||||
|
'rolls=$_rollsRemaining, undo=$_undoRemaining',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 저장된 상태 로드
|
||||||
|
Future<void> _loadState() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_rollsRemaining = prefs.getInt(_rollsRemainingKey) ?? maxRolls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 굴리기 횟수 저장
|
||||||
|
Future<void> _saveRollsRemaining() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_rollsRemainingKey, _rollsRemaining);
|
||||||
|
debugPrint('[CharacterRollService] Saved rolls: $_rollsRemaining');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 새 세션 시작 시 되돌리기 초기화
|
||||||
|
void _resetUndoForNewSession() {
|
||||||
|
_undoRemaining = _isPaidUser ? maxUndoPaidUser : maxUndoFreeUser;
|
||||||
|
_rollHistory.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 유료 사용자 확인
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 유료 사용자 여부
|
||||||
|
bool get _isPaidUser => IAPService.instance.isAdRemovalPurchased;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 굴리기
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 남은 굴리기 횟수
|
||||||
|
int get rollsRemaining => _rollsRemaining;
|
||||||
|
|
||||||
|
/// 굴리기 가능 여부
|
||||||
|
bool get canRoll => _rollsRemaining > 0;
|
||||||
|
|
||||||
|
/// 굴리기 실행
|
||||||
|
///
|
||||||
|
/// [currentStats] 현재 스탯 (되돌리기용 저장)
|
||||||
|
/// [currentRaceIndex] 현재 종족 인덱스
|
||||||
|
/// [currentKlassIndex] 현재 직업 인덱스
|
||||||
|
/// [currentSeed] 현재 RNG 시드
|
||||||
|
/// Returns: 굴리기 성공 여부
|
||||||
|
bool roll({
|
||||||
|
required Stats currentStats,
|
||||||
|
required int currentRaceIndex,
|
||||||
|
required int currentKlassIndex,
|
||||||
|
required int currentSeed,
|
||||||
|
}) {
|
||||||
|
if (!canRoll) {
|
||||||
|
debugPrint('[CharacterRollService] Cannot roll: no rolls remaining');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 상태를 히스토리에 저장
|
||||||
|
_rollHistory.insert(
|
||||||
|
0,
|
||||||
|
RollSnapshot(
|
||||||
|
stats: currentStats,
|
||||||
|
raceIndex: currentRaceIndex,
|
||||||
|
klassIndex: currentKlassIndex,
|
||||||
|
seed: currentSeed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 최대 히스토리 개수 제한 (되돌리기 가능 횟수만큼)
|
||||||
|
final maxHistory = _isPaidUser ? maxUndoPaidUser : maxUndoFreeUser;
|
||||||
|
while (_rollHistory.length > maxHistory) {
|
||||||
|
_rollHistory.removeLast();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 굴리기 횟수 감소
|
||||||
|
_rollsRemaining--;
|
||||||
|
_saveRollsRemaining();
|
||||||
|
|
||||||
|
// 매 굴림마다 되돌리기 횟수 리셋
|
||||||
|
// - 유료 유저: 3회
|
||||||
|
// - 무료 유저: 1회 (광고 시청 필요)
|
||||||
|
_undoRemaining = _isPaidUser ? maxUndoPaidUser : maxUndoFreeUser;
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'[CharacterRollService] Rolled: remaining=$_rollsRemaining, '
|
||||||
|
'history=${_rollHistory.length}, undo=$_undoRemaining',
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 광고 시청 후 굴리기 충전
|
||||||
|
Future<bool> rechargeRollsWithAd() async {
|
||||||
|
// 유료 사용자는 광고 없이 충전
|
||||||
|
if (_isPaidUser) {
|
||||||
|
_rollsRemaining = maxRolls;
|
||||||
|
await _saveRollsRemaining();
|
||||||
|
debugPrint('[CharacterRollService] Recharged (paid user): $maxRolls');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인터스티셜 광고 표시
|
||||||
|
final result = await AdService.instance.showInterstitialAd(
|
||||||
|
adType: AdType.interstitialRoll,
|
||||||
|
onComplete: () {
|
||||||
|
_rollsRemaining = maxRolls;
|
||||||
|
_saveRollsRemaining();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == AdResult.completed || result == AdResult.debugSkipped) {
|
||||||
|
debugPrint('[CharacterRollService] Recharged with ad: $maxRolls');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[CharacterRollService] Recharge failed: $result');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 되돌리기
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 남은 되돌리기 횟수
|
||||||
|
int get undoRemaining => _undoRemaining;
|
||||||
|
|
||||||
|
/// 되돌리기 히스토리 길이
|
||||||
|
int get historyLength => _rollHistory.length;
|
||||||
|
|
||||||
|
/// 실제 사용 가능한 되돌리기 횟수
|
||||||
|
/// min(undoRemaining, historyLength)
|
||||||
|
int get availableUndos {
|
||||||
|
final available = _undoRemaining < _rollHistory.length
|
||||||
|
? _undoRemaining
|
||||||
|
: _rollHistory.length;
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 되돌리기 가능 여부
|
||||||
|
bool get canUndo => availableUndos > 0;
|
||||||
|
|
||||||
|
/// 되돌리기 실행 (유료 사용자)
|
||||||
|
///
|
||||||
|
/// Returns: 복원된 스냅샷 (null이면 실패)
|
||||||
|
RollSnapshot? undoPaidUser() {
|
||||||
|
if (!_isPaidUser) return null;
|
||||||
|
if (!canUndo) return null;
|
||||||
|
|
||||||
|
final snapshot = _rollHistory.removeAt(0);
|
||||||
|
_undoRemaining--;
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'[CharacterRollService] Undo (paid): '
|
||||||
|
'remaining=$_undoRemaining, history=${_rollHistory.length}',
|
||||||
|
);
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 되돌리기 실행 (무료 사용자 - 광고 필요)
|
||||||
|
///
|
||||||
|
/// [onSuccess] 광고 시청 완료 후 콜백
|
||||||
|
Future<RollSnapshot?> undoFreeUser() async {
|
||||||
|
if (_isPaidUser) return undoPaidUser();
|
||||||
|
if (!canUndo) return null;
|
||||||
|
|
||||||
|
// 리워드 광고 표시
|
||||||
|
RollSnapshot? result;
|
||||||
|
|
||||||
|
final adResult = await AdService.instance.showRewardedAd(
|
||||||
|
adType: AdType.rewardUndo,
|
||||||
|
onRewarded: () {
|
||||||
|
if (_rollHistory.isNotEmpty) {
|
||||||
|
result = _rollHistory.removeAt(0);
|
||||||
|
_undoRemaining--;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) {
|
||||||
|
debugPrint(
|
||||||
|
'[CharacterRollService] Undo (free with ad): '
|
||||||
|
'remaining=$_undoRemaining, history=${_rollHistory.length}',
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[CharacterRollService] Undo failed: $adResult');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 캐릭터 생성 완료
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 캐릭터 생성 완료 시 호출
|
||||||
|
///
|
||||||
|
/// 되돌리기 상태만 초기화 (굴리기 횟수는 유지)
|
||||||
|
void onCharacterCreated() {
|
||||||
|
_resetUndoForNewSession();
|
||||||
|
debugPrint('[CharacterRollService] Character created, undo reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 굴리기 횟수 완전 초기화 (디버그용)
|
||||||
|
Future<void> resetRolls() async {
|
||||||
|
_rollsRemaining = maxRolls;
|
||||||
|
await _saveRollsRemaining();
|
||||||
|
_resetUndoForNewSession();
|
||||||
|
debugPrint('[CharacterRollService] Reset all');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 굴리기 스냅샷 (되돌리기용)
|
||||||
|
class RollSnapshot {
|
||||||
|
const RollSnapshot({
|
||||||
|
required this.stats,
|
||||||
|
required this.raceIndex,
|
||||||
|
required this.klassIndex,
|
||||||
|
required this.seed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Stats stats;
|
||||||
|
final int raceIndex;
|
||||||
|
final int klassIndex;
|
||||||
|
final int seed;
|
||||||
|
}
|
||||||
282
lib/src/core/engine/chest_service.dart
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/data/potion_data.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/core/model/treasure_chest.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||||
|
|
||||||
|
/// 보물 상자 서비스
|
||||||
|
///
|
||||||
|
/// 상자 내용물 생성 및 오픈 로직 담당
|
||||||
|
class ChestService {
|
||||||
|
ChestService({DeterministicRandom? rng})
|
||||||
|
: _rng = rng ?? DeterministicRandom(DateTime.now().millisecondsSinceEpoch);
|
||||||
|
|
||||||
|
final DeterministicRandom _rng;
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// 상수
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/// 보상 타입별 확률 (%)
|
||||||
|
static const int _equipmentChance = 40; // 장비 40%
|
||||||
|
static const int _potionChance = 30; // 포션 30%
|
||||||
|
static const int _goldChance = 20; // 골드 20%
|
||||||
|
// 경험치 10% (나머지)
|
||||||
|
|
||||||
|
/// 골드 보상 범위 (레벨 * 배율)
|
||||||
|
static const int _goldPerLevel = 50;
|
||||||
|
static const int _goldVariance = 20;
|
||||||
|
|
||||||
|
/// 경험치 보상 범위 (레벨 * 배율)
|
||||||
|
static const int _expPerLevel = 100;
|
||||||
|
static const int _expVariance = 30;
|
||||||
|
|
||||||
|
/// 포션 수량 범위
|
||||||
|
static const int _minPotionCount = 1;
|
||||||
|
static const int _maxPotionCount = 3;
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// 상자 오픈
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/// 상자 오픈하여 보상 생성
|
||||||
|
///
|
||||||
|
/// [playerLevel] 플레이어 레벨 (보상 스케일링용)
|
||||||
|
ChestReward openChest(int playerLevel) {
|
||||||
|
final roll = _rng.nextInt(100);
|
||||||
|
|
||||||
|
if (roll < _equipmentChance) {
|
||||||
|
// 40%: 장비
|
||||||
|
return _generateEquipmentReward(playerLevel);
|
||||||
|
} else if (roll < _equipmentChance + _potionChance) {
|
||||||
|
// 30%: 포션
|
||||||
|
return _generatePotionReward(playerLevel);
|
||||||
|
} else if (roll < _equipmentChance + _potionChance + _goldChance) {
|
||||||
|
// 20%: 골드
|
||||||
|
return _generateGoldReward(playerLevel);
|
||||||
|
} else {
|
||||||
|
// 10%: 경험치
|
||||||
|
return _generateExperienceReward(playerLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 여러 상자 오픈
|
||||||
|
List<ChestReward> openMultipleChests(int count, int playerLevel) {
|
||||||
|
final rewards = <ChestReward>[];
|
||||||
|
for (var i = 0; i < count; i++) {
|
||||||
|
rewards.add(openChest(playerLevel));
|
||||||
|
}
|
||||||
|
return rewards;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// 보상 생성
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/// 장비 보상 생성
|
||||||
|
ChestReward _generateEquipmentReward(int playerLevel) {
|
||||||
|
// 랜덤 슬롯 선택
|
||||||
|
final slotIndex = _rng.nextInt(EquipmentSlot.values.length);
|
||||||
|
final slot = EquipmentSlot.values[slotIndex];
|
||||||
|
|
||||||
|
// 희귀도 결정 (상자는 좀 더 좋은 확률)
|
||||||
|
final rarity = _rollChestRarity();
|
||||||
|
|
||||||
|
// 아이템 레벨: 플레이어 레벨 ±2
|
||||||
|
final minLevel = (playerLevel - 2).clamp(1, 999);
|
||||||
|
final maxLevel = playerLevel + 2;
|
||||||
|
final itemLevel = minLevel + _rng.nextInt(maxLevel - minLevel + 1);
|
||||||
|
|
||||||
|
// 아이템 생성
|
||||||
|
final item = EquipmentItem(
|
||||||
|
name: _generateItemName(slot, rarity, itemLevel),
|
||||||
|
slot: slot,
|
||||||
|
level: itemLevel,
|
||||||
|
weight: _calculateWeight(slot, itemLevel),
|
||||||
|
stats: _generateItemStats(slot, itemLevel, rarity),
|
||||||
|
rarity: rarity,
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'[ChestService] Equipment reward: ${item.name} (${rarity.name})',
|
||||||
|
);
|
||||||
|
return ChestReward.equipment(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 포션 보상 생성
|
||||||
|
ChestReward _generatePotionReward(int playerLevel) {
|
||||||
|
// 레벨에 맞는 티어 선택
|
||||||
|
final tier = PotionData.tierForLevel(playerLevel);
|
||||||
|
|
||||||
|
// HP/MP 랜덤 선택
|
||||||
|
final isHp = _rng.nextInt(2) == 0;
|
||||||
|
final potion = isHp
|
||||||
|
? PotionData.getHpPotionByTier(tier)
|
||||||
|
: PotionData.getMpPotionByTier(tier);
|
||||||
|
|
||||||
|
if (potion == null) {
|
||||||
|
// 폴백: 기본 포션
|
||||||
|
return ChestReward.potion('minor_health_patch', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수량 결정
|
||||||
|
final count =
|
||||||
|
_minPotionCount + _rng.nextInt(_maxPotionCount - _minPotionCount + 1);
|
||||||
|
|
||||||
|
debugPrint('[ChestService] Potion reward: ${potion.name} x$count');
|
||||||
|
return ChestReward.potion(potion.id, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 골드 보상 생성
|
||||||
|
ChestReward _generateGoldReward(int playerLevel) {
|
||||||
|
final baseGold = playerLevel * _goldPerLevel;
|
||||||
|
final variance = _rng.nextInt(_goldVariance * 2 + 1) - _goldVariance;
|
||||||
|
final gold = (baseGold + (baseGold * variance / 100)).round().clamp(
|
||||||
|
10,
|
||||||
|
99999,
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('[ChestService] Gold reward: $gold');
|
||||||
|
return ChestReward.gold(gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 경험치 보상 생성
|
||||||
|
ChestReward _generateExperienceReward(int playerLevel) {
|
||||||
|
final baseExp = playerLevel * _expPerLevel;
|
||||||
|
final variance = _rng.nextInt(_expVariance * 2 + 1) - _expVariance;
|
||||||
|
final exp = (baseExp + (baseExp * variance / 100)).round().clamp(
|
||||||
|
10,
|
||||||
|
999999,
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('[ChestService] Experience reward: $exp');
|
||||||
|
return ChestReward.experience(exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// 헬퍼 메서드
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/// 상자 희귀도 롤 (일반 샵보다 좋은 확률)
|
||||||
|
/// Common 50%, Uncommon 30%, Rare 15%, Epic 4%, Legendary 1%
|
||||||
|
ItemRarity _rollChestRarity() {
|
||||||
|
final roll = _rng.nextInt(100);
|
||||||
|
if (roll < 50) return ItemRarity.common;
|
||||||
|
if (roll < 80) return ItemRarity.uncommon;
|
||||||
|
if (roll < 95) return ItemRarity.rare;
|
||||||
|
if (roll < 99) return ItemRarity.epic;
|
||||||
|
return ItemRarity.legendary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 아이템 이름 생성
|
||||||
|
String _generateItemName(EquipmentSlot slot, ItemRarity rarity, int level) {
|
||||||
|
final prefix = _getRarityPrefix(rarity);
|
||||||
|
final baseName = _getSlotBaseName(slot);
|
||||||
|
final suffix = level > 10 ? ' +${level ~/ 10}' : '';
|
||||||
|
return '$prefix$baseName$suffix'.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getRarityPrefix(ItemRarity rarity) {
|
||||||
|
return switch (rarity) {
|
||||||
|
ItemRarity.common => '',
|
||||||
|
ItemRarity.uncommon => 'Fine ',
|
||||||
|
ItemRarity.rare => 'Superior ',
|
||||||
|
ItemRarity.epic => 'Epic ',
|
||||||
|
ItemRarity.legendary => 'Legendary ',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getSlotBaseName(EquipmentSlot slot) {
|
||||||
|
return switch (slot) {
|
||||||
|
EquipmentSlot.weapon => 'Keyboard',
|
||||||
|
EquipmentSlot.shield => 'Firewall Shield',
|
||||||
|
EquipmentSlot.helm => 'Neural Headset',
|
||||||
|
EquipmentSlot.hauberk => 'Server Rack Armor',
|
||||||
|
EquipmentSlot.brassairts => 'Cable Brassairts',
|
||||||
|
EquipmentSlot.vambraces => 'USB Vambraces',
|
||||||
|
EquipmentSlot.gauntlets => 'Typing Gauntlets',
|
||||||
|
EquipmentSlot.gambeson => 'Padded Gambeson',
|
||||||
|
EquipmentSlot.cuisses => 'Circuit Cuisses',
|
||||||
|
EquipmentSlot.greaves => 'Copper Greaves',
|
||||||
|
EquipmentSlot.sollerets => 'Static Boots',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 스탯 생성
|
||||||
|
ItemStats _generateItemStats(
|
||||||
|
EquipmentSlot slot,
|
||||||
|
int level,
|
||||||
|
ItemRarity rarity,
|
||||||
|
) {
|
||||||
|
final multiplier = rarity.multiplier;
|
||||||
|
final baseValue = (level * multiplier).round();
|
||||||
|
|
||||||
|
return switch (slot) {
|
||||||
|
EquipmentSlot.weapon => ItemStats(
|
||||||
|
atk: baseValue * 2,
|
||||||
|
criRate: 0.01 * (level ~/ 5),
|
||||||
|
parryRate: 0.005 * level,
|
||||||
|
),
|
||||||
|
EquipmentSlot.shield => ItemStats(
|
||||||
|
def: baseValue,
|
||||||
|
blockRate: 0.02 * (level ~/ 3).clamp(1, 10),
|
||||||
|
),
|
||||||
|
EquipmentSlot.helm => ItemStats(
|
||||||
|
def: baseValue ~/ 2,
|
||||||
|
magDef: baseValue ~/ 2,
|
||||||
|
intBonus: level ~/ 10,
|
||||||
|
),
|
||||||
|
EquipmentSlot.hauberk => ItemStats(def: baseValue, hpBonus: level * 2),
|
||||||
|
EquipmentSlot.brassairts => ItemStats(
|
||||||
|
def: baseValue ~/ 2,
|
||||||
|
strBonus: level ~/ 15,
|
||||||
|
),
|
||||||
|
EquipmentSlot.vambraces => ItemStats(
|
||||||
|
def: baseValue ~/ 2,
|
||||||
|
dexBonus: level ~/ 15,
|
||||||
|
),
|
||||||
|
EquipmentSlot.gauntlets => ItemStats(
|
||||||
|
atk: baseValue ~/ 2,
|
||||||
|
def: baseValue ~/ 4,
|
||||||
|
),
|
||||||
|
EquipmentSlot.gambeson => ItemStats(
|
||||||
|
def: baseValue ~/ 2,
|
||||||
|
conBonus: level ~/ 15,
|
||||||
|
),
|
||||||
|
EquipmentSlot.cuisses => ItemStats(
|
||||||
|
def: baseValue ~/ 2,
|
||||||
|
evasion: 0.005 * level,
|
||||||
|
),
|
||||||
|
EquipmentSlot.greaves => ItemStats(
|
||||||
|
def: baseValue ~/ 2,
|
||||||
|
evasion: 0.003 * level,
|
||||||
|
),
|
||||||
|
EquipmentSlot.sollerets => ItemStats(
|
||||||
|
def: baseValue ~/ 3,
|
||||||
|
evasion: 0.002 * level,
|
||||||
|
dexBonus: level ~/ 20,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 무게 계산
|
||||||
|
int _calculateWeight(EquipmentSlot slot, int level) {
|
||||||
|
final baseWeight = switch (slot) {
|
||||||
|
EquipmentSlot.weapon => 5,
|
||||||
|
EquipmentSlot.shield => 8,
|
||||||
|
EquipmentSlot.helm => 4,
|
||||||
|
EquipmentSlot.hauberk => 15,
|
||||||
|
EquipmentSlot.brassairts => 3,
|
||||||
|
EquipmentSlot.vambraces => 3,
|
||||||
|
EquipmentSlot.gauntlets => 2,
|
||||||
|
EquipmentSlot.gambeson => 6,
|
||||||
|
EquipmentSlot.cuisses => 5,
|
||||||
|
EquipmentSlot.greaves => 4,
|
||||||
|
EquipmentSlot.sollerets => 3,
|
||||||
|
};
|
||||||
|
return baseWeight + (level ~/ 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:askiineverdie/src/core/model/combat_result.dart';
|
import 'package:asciineverdie/src/core/model/combat_result.dart';
|
||||||
import 'package:askiineverdie/src/core/model/combat_stats.dart';
|
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||||
import 'package:askiineverdie/src/core/model/monster_combat_stats.dart';
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
|
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||||
|
|
||||||
/// 전투 계산 서비스
|
/// 전투 계산 서비스
|
||||||
///
|
///
|
||||||
@@ -98,8 +98,9 @@ class CombatCalculator {
|
|||||||
final isParried = parryRoll < defenderParryRate;
|
final isParried = parryRoll < defenderParryRate;
|
||||||
|
|
||||||
// 3. 기본 데미지 계산 (0.8 ~ 1.2 변동)
|
// 3. 기본 데미지 계산 (0.8 ~ 1.2 변동)
|
||||||
|
// DEF 감산 비율: 0.4 (전체 데미지 상승 조정)
|
||||||
final damageVariation = 0.8 + rng.nextDouble() * 0.4;
|
final damageVariation = 0.8 + rng.nextDouble() * 0.4;
|
||||||
var baseDamage = (attackerAtk * damageVariation - defenderDef * 0.5);
|
var baseDamage = (attackerAtk * damageVariation - defenderDef * 0.4);
|
||||||
|
|
||||||
// 4. 크리티컬 판정
|
// 4. 크리티컬 판정
|
||||||
final criRoll = rng.nextDouble();
|
final criRoll = rng.nextDouble();
|
||||||
@@ -206,7 +207,7 @@ class CombatCalculator {
|
|||||||
required MonsterCombatStats monster,
|
required MonsterCombatStats monster,
|
||||||
}) {
|
}) {
|
||||||
// 플레이어 DPS (초당 데미지)
|
// 플레이어 DPS (초당 데미지)
|
||||||
final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.5);
|
final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.4);
|
||||||
final playerHitsPerSecond = 1000 / player.attackDelayMs;
|
final playerHitsPerSecond = 1000 / player.attackDelayMs;
|
||||||
final playerDps =
|
final playerDps =
|
||||||
playerDamagePerHit * playerHitsPerSecond * player.accuracy;
|
playerDamagePerHit * playerHitsPerSecond * player.accuracy;
|
||||||
@@ -226,14 +227,14 @@ class CombatCalculator {
|
|||||||
required MonsterCombatStats monster,
|
required MonsterCombatStats monster,
|
||||||
}) {
|
}) {
|
||||||
// 플레이어 예상 생존 시간
|
// 플레이어 예상 생존 시간
|
||||||
final monsterDamagePerHit = math.max(1, monster.atk - player.def * 0.5);
|
final monsterDamagePerHit = math.max(1, monster.atk - player.def * 0.4);
|
||||||
final monsterHitsPerSecond = 1000 / monster.attackDelayMs;
|
final monsterHitsPerSecond = 1000 / monster.attackDelayMs;
|
||||||
final monsterDps =
|
final monsterDps =
|
||||||
monsterDamagePerHit * monsterHitsPerSecond * monster.accuracy;
|
monsterDamagePerHit * monsterHitsPerSecond * monster.accuracy;
|
||||||
final playerSurvivalTime = player.hpCurrent / monsterDps;
|
final playerSurvivalTime = player.hpCurrent / monsterDps;
|
||||||
|
|
||||||
// 몬스터 예상 생존 시간
|
// 몬스터 예상 생존 시간
|
||||||
final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.5);
|
final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.4);
|
||||||
final playerHitsPerSecond = 1000 / player.attackDelayMs;
|
final playerHitsPerSecond = 1000 / player.attackDelayMs;
|
||||||
final playerDps =
|
final playerDps =
|
||||||
playerDamagePerHit * playerHitsPerSecond * player.accuracy;
|
playerDamagePerHit * playerHitsPerSecond * player.accuracy;
|
||||||
|
|||||||
443
lib/src/core/engine/combat_tick_service.dart
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
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';
|
||||||
|
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/potion.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||||
|
|
||||||
|
/// 전투 틱 처리 결과
|
||||||
|
class CombatTickResult {
|
||||||
|
const CombatTickResult({
|
||||||
|
required this.combat,
|
||||||
|
required this.skillSystem,
|
||||||
|
this.potionInventory,
|
||||||
|
});
|
||||||
|
|
||||||
|
final CombatState combat;
|
||||||
|
final SkillSystemState skillSystem;
|
||||||
|
final PotionInventory? potionInventory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 전투 틱 처리 서비스
|
||||||
|
///
|
||||||
|
/// ProgressService에서 분리된 전투 로직 담당:
|
||||||
|
/// - 스킬 자동 사용
|
||||||
|
/// - DOT 처리
|
||||||
|
/// - 물약 자동 사용
|
||||||
|
/// - 플레이어/몬스터 공격 처리
|
||||||
|
class CombatTickService {
|
||||||
|
CombatTickService({required this.rng});
|
||||||
|
|
||||||
|
final DeterministicRandom rng;
|
||||||
|
|
||||||
|
/// 전투 틱 처리 (스킬 자동 사용, DOT, 물약 포함)
|
||||||
|
///
|
||||||
|
/// [state] 현재 게임 상태
|
||||||
|
/// [combat] 현재 전투 상태
|
||||||
|
/// [skillSystem] 스킬 시스템 상태
|
||||||
|
/// [elapsedMs] 경과 시간 (밀리초)
|
||||||
|
CombatTickResult processTick({
|
||||||
|
required GameState state,
|
||||||
|
required CombatState combat,
|
||||||
|
required SkillSystemState skillSystem,
|
||||||
|
required int elapsedMs,
|
||||||
|
}) {
|
||||||
|
if (!combat.isActive || combat.isCombatOver) {
|
||||||
|
return CombatTickResult(
|
||||||
|
combat: combat,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
potionInventory: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final calculator = CombatCalculator(rng: rng);
|
||||||
|
final skillService = SkillService(rng: rng);
|
||||||
|
final potionService = const PotionService();
|
||||||
|
var playerStats = combat.playerStats;
|
||||||
|
var monsterStats = combat.monsterStats;
|
||||||
|
var playerAccumulator = combat.playerAttackAccumulatorMs + elapsedMs;
|
||||||
|
var monsterAccumulator = combat.monsterAttackAccumulatorMs + elapsedMs;
|
||||||
|
var totalDamageDealt = combat.totalDamageDealt;
|
||||||
|
var totalDamageTaken = combat.totalDamageTaken;
|
||||||
|
var turnsElapsed = combat.turnsElapsed;
|
||||||
|
var updatedSkillSystem = skillSystem;
|
||||||
|
var activeDoTs = [...combat.activeDoTs];
|
||||||
|
var lastPotionUsedMs = combat.lastPotionUsedMs;
|
||||||
|
var activeDebuffs = [...combat.activeDebuffs];
|
||||||
|
PotionInventory? updatedPotionInventory;
|
||||||
|
|
||||||
|
// 새 전투 이벤트 수집
|
||||||
|
final newEvents = <CombatEvent>[];
|
||||||
|
final timestamp = updatedSkillSystem.elapsedMs;
|
||||||
|
|
||||||
|
// 만료된 디버프 정리
|
||||||
|
activeDebuffs = activeDebuffs
|
||||||
|
.where((debuff) => !debuff.isExpired(timestamp))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// DOT 틱 처리
|
||||||
|
final dotResult = _processDotTicks(
|
||||||
|
activeDoTs: activeDoTs,
|
||||||
|
monsterStats: monsterStats,
|
||||||
|
elapsedMs: elapsedMs,
|
||||||
|
timestamp: timestamp,
|
||||||
|
totalDamageDealt: totalDamageDealt,
|
||||||
|
);
|
||||||
|
activeDoTs = dotResult.activeDoTs;
|
||||||
|
monsterStats = dotResult.monsterStats;
|
||||||
|
totalDamageDealt = dotResult.totalDamageDealt;
|
||||||
|
newEvents.addAll(dotResult.events);
|
||||||
|
|
||||||
|
// 클래스 패시브 조회 (healingBonus, firstStrikeBonus, multiAttack)
|
||||||
|
final klass = ClassData.findById(state.traits.classId);
|
||||||
|
final healingBonus =
|
||||||
|
klass?.getPassiveValue(ClassPassiveType.healingBonus) ?? 0.0;
|
||||||
|
final healingMultiplier = 1.0 + healingBonus;
|
||||||
|
final firstStrikeBonus =
|
||||||
|
klass?.getPassiveValue(ClassPassiveType.firstStrikeBonus) ?? 0.0;
|
||||||
|
final hasMultiAttack =
|
||||||
|
klass?.hasPassive(ClassPassiveType.multiAttack) ?? false;
|
||||||
|
var isFirstPlayerAttack = combat.isFirstPlayerAttack;
|
||||||
|
|
||||||
|
// 긴급 물약 자동 사용 (HP < 30% 또는 MP < 50%)
|
||||||
|
final potionResult = _tryEmergencyPotion(
|
||||||
|
playerStats: playerStats,
|
||||||
|
potionInventory: state.potionInventory,
|
||||||
|
lastPotionUsedMs: lastPotionUsedMs,
|
||||||
|
playerLevel: state.traits.level,
|
||||||
|
timestamp: timestamp,
|
||||||
|
potionService: potionService,
|
||||||
|
healingMultiplier: healingMultiplier,
|
||||||
|
);
|
||||||
|
if (potionResult != null) {
|
||||||
|
playerStats = potionResult.playerStats;
|
||||||
|
lastPotionUsedMs = potionResult.lastPotionUsedMs;
|
||||||
|
updatedPotionInventory = potionResult.potionInventory;
|
||||||
|
newEvents.addAll(potionResult.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 플레이어 공격 체크
|
||||||
|
if (playerAccumulator >= playerStats.attackDelayMs) {
|
||||||
|
final attackProcessor = PlayerAttackProcessor(rng: rng);
|
||||||
|
final attackResult = attackProcessor.processAttack(
|
||||||
|
state: state,
|
||||||
|
playerStats: playerStats,
|
||||||
|
monsterStats: monsterStats,
|
||||||
|
updatedSkillSystem: updatedSkillSystem,
|
||||||
|
activeDoTs: activeDoTs,
|
||||||
|
activeDebuffs: activeDebuffs,
|
||||||
|
totalDamageDealt: totalDamageDealt,
|
||||||
|
timestamp: timestamp,
|
||||||
|
calculator: calculator,
|
||||||
|
skillService: skillService,
|
||||||
|
isFirstPlayerAttack: isFirstPlayerAttack,
|
||||||
|
firstStrikeBonus: firstStrikeBonus > 0 ? firstStrikeBonus : 1.0,
|
||||||
|
hasMultiAttack: hasMultiAttack,
|
||||||
|
healingMultiplier: healingMultiplier,
|
||||||
|
);
|
||||||
|
|
||||||
|
playerStats = attackResult.playerStats;
|
||||||
|
monsterStats = attackResult.monsterStats;
|
||||||
|
updatedSkillSystem = attackResult.skillSystem;
|
||||||
|
activeDoTs = attackResult.activeDoTs;
|
||||||
|
activeDebuffs = attackResult.activeDebuffs;
|
||||||
|
totalDamageDealt = attackResult.totalDamageDealt;
|
||||||
|
newEvents.addAll(attackResult.events);
|
||||||
|
isFirstPlayerAttack = attackResult.isFirstPlayerAttack;
|
||||||
|
|
||||||
|
playerAccumulator -= playerStats.attackDelayMs;
|
||||||
|
turnsElapsed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 몬스터가 살아있으면 반격
|
||||||
|
if (monsterStats.isAlive &&
|
||||||
|
monsterAccumulator >= monsterStats.attackDelayMs) {
|
||||||
|
final monsterAttackResult = _processMonsterAttack(
|
||||||
|
playerStats: playerStats,
|
||||||
|
monsterStats: monsterStats,
|
||||||
|
activeDebuffs: activeDebuffs,
|
||||||
|
totalDamageTaken: totalDamageTaken,
|
||||||
|
timestamp: timestamp,
|
||||||
|
calculator: calculator,
|
||||||
|
);
|
||||||
|
|
||||||
|
playerStats = monsterAttackResult.playerStats;
|
||||||
|
totalDamageTaken = monsterAttackResult.totalDamageTaken;
|
||||||
|
newEvents.addAll(monsterAttackResult.events);
|
||||||
|
monsterAccumulator -= monsterStats.attackDelayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전투 종료 체크
|
||||||
|
final isActive = playerStats.isAlive && monsterStats.isAlive;
|
||||||
|
|
||||||
|
// 기존 이벤트와 합쳐서 최대 10개 유지
|
||||||
|
final combinedEvents = [...combat.recentEvents, ...newEvents];
|
||||||
|
final recentEvents = combinedEvents.length > 10
|
||||||
|
? combinedEvents.sublist(combinedEvents.length - 10)
|
||||||
|
: combinedEvents;
|
||||||
|
|
||||||
|
return CombatTickResult(
|
||||||
|
combat: combat.copyWith(
|
||||||
|
playerStats: playerStats,
|
||||||
|
monsterStats: monsterStats,
|
||||||
|
playerAttackAccumulatorMs: playerAccumulator,
|
||||||
|
monsterAttackAccumulatorMs: monsterAccumulator,
|
||||||
|
totalDamageDealt: totalDamageDealt,
|
||||||
|
totalDamageTaken: totalDamageTaken,
|
||||||
|
turnsElapsed: turnsElapsed,
|
||||||
|
isActive: isActive,
|
||||||
|
recentEvents: recentEvents,
|
||||||
|
activeDoTs: activeDoTs,
|
||||||
|
lastPotionUsedMs: lastPotionUsedMs,
|
||||||
|
activeDebuffs: activeDebuffs,
|
||||||
|
isFirstPlayerAttack: isFirstPlayerAttack,
|
||||||
|
),
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
potionInventory: updatedPotionInventory,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DOT 틱 처리
|
||||||
|
({
|
||||||
|
List<DotEffect> activeDoTs,
|
||||||
|
MonsterCombatStats monsterStats,
|
||||||
|
int totalDamageDealt,
|
||||||
|
List<CombatEvent> events,
|
||||||
|
})
|
||||||
|
_processDotTicks({
|
||||||
|
required List<DotEffect> activeDoTs,
|
||||||
|
required MonsterCombatStats monsterStats,
|
||||||
|
required int elapsedMs,
|
||||||
|
required int timestamp,
|
||||||
|
required int totalDamageDealt,
|
||||||
|
}) {
|
||||||
|
var dotDamageThisTick = 0;
|
||||||
|
final updatedDoTs = <DotEffect>[];
|
||||||
|
final events = <CombatEvent>[];
|
||||||
|
var updatedMonster = monsterStats;
|
||||||
|
|
||||||
|
for (final dot in activeDoTs) {
|
||||||
|
final (updatedDot, ticksTriggered) = dot.tick(elapsedMs);
|
||||||
|
|
||||||
|
if (ticksTriggered > 0) {
|
||||||
|
final damage = dot.damagePerTick * ticksTriggered;
|
||||||
|
dotDamageThisTick += damage;
|
||||||
|
|
||||||
|
// DOT 데미지 이벤트 생성
|
||||||
|
final dotSkillName =
|
||||||
|
SkillData.getSkillById(dot.skillId)?.name ?? dot.skillId;
|
||||||
|
events.add(
|
||||||
|
CombatEvent.dotTick(
|
||||||
|
timestamp: timestamp,
|
||||||
|
skillName: dotSkillName,
|
||||||
|
damage: damage,
|
||||||
|
targetName: updatedMonster.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 만료되지 않은 DOT만 유지
|
||||||
|
if (updatedDot.isActive) {
|
||||||
|
updatedDoTs.add(updatedDot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOT 데미지 적용
|
||||||
|
if (dotDamageThisTick > 0 && updatedMonster.isAlive) {
|
||||||
|
final newMonsterHp = (updatedMonster.hpCurrent - dotDamageThisTick).clamp(
|
||||||
|
0,
|
||||||
|
updatedMonster.hpMax,
|
||||||
|
);
|
||||||
|
updatedMonster = updatedMonster.copyWith(hpCurrent: newMonsterHp);
|
||||||
|
totalDamageDealt += dotDamageThisTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
activeDoTs: updatedDoTs,
|
||||||
|
monsterStats: updatedMonster,
|
||||||
|
totalDamageDealt: totalDamageDealt,
|
||||||
|
events: events,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 긴급 물약 자동 사용 (HP/MP 통합 글로벌 쿨타임)
|
||||||
|
({
|
||||||
|
CombatStats playerStats,
|
||||||
|
int lastPotionUsedMs,
|
||||||
|
PotionInventory potionInventory,
|
||||||
|
List<CombatEvent> events,
|
||||||
|
})?
|
||||||
|
_tryEmergencyPotion({
|
||||||
|
required CombatStats playerStats,
|
||||||
|
required PotionInventory potionInventory,
|
||||||
|
required int lastPotionUsedMs,
|
||||||
|
required int playerLevel,
|
||||||
|
required int timestamp,
|
||||||
|
required PotionService potionService,
|
||||||
|
double healingMultiplier = 1.0,
|
||||||
|
}) {
|
||||||
|
// 글로벌 쿨타임 체크
|
||||||
|
if (timestamp - lastPotionUsedMs < PotionService.globalPotionCooldownMs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 우선순위 1: HP 물약 (소모된 HP >= 물약 회복량)
|
||||||
|
final hpPotion = potionService.selectEmergencyHpPotion(
|
||||||
|
currentHp: playerStats.hpCurrent,
|
||||||
|
maxHp: playerStats.hpMax,
|
||||||
|
inventory: potionInventory,
|
||||||
|
playerLevel: playerLevel,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hpPotion != null) {
|
||||||
|
final result = potionService.usePotion(
|
||||||
|
potionId: hpPotion.id,
|
||||||
|
inventory: potionInventory,
|
||||||
|
currentHp: playerStats.hpCurrent,
|
||||||
|
maxHp: playerStats.hpMax,
|
||||||
|
currentMp: playerStats.mpCurrent,
|
||||||
|
maxMp: playerStats.mpMax,
|
||||||
|
healingMultiplier: healingMultiplier,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return (
|
||||||
|
playerStats: playerStats.copyWith(hpCurrent: result.newHp),
|
||||||
|
lastPotionUsedMs: timestamp,
|
||||||
|
potionInventory: result.newInventory!,
|
||||||
|
events: [
|
||||||
|
CombatEvent.playerPotion(
|
||||||
|
timestamp: timestamp,
|
||||||
|
potionName: hpPotion.name,
|
||||||
|
healAmount: result.healedAmount,
|
||||||
|
isHp: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 우선순위 2: MP 물약 (소모된 MP >= 물약 회복량)
|
||||||
|
final mpPotion = potionService.selectEmergencyMpPotion(
|
||||||
|
currentMp: playerStats.mpCurrent,
|
||||||
|
maxMp: playerStats.mpMax,
|
||||||
|
inventory: potionInventory,
|
||||||
|
playerLevel: playerLevel,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mpPotion != null) {
|
||||||
|
final result = potionService.usePotion(
|
||||||
|
potionId: mpPotion.id,
|
||||||
|
inventory: potionInventory,
|
||||||
|
currentHp: playerStats.hpCurrent,
|
||||||
|
maxHp: playerStats.hpMax,
|
||||||
|
currentMp: playerStats.mpCurrent,
|
||||||
|
maxMp: playerStats.mpMax,
|
||||||
|
healingMultiplier: healingMultiplier,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return (
|
||||||
|
playerStats: playerStats.copyWith(mpCurrent: result.newMp),
|
||||||
|
lastPotionUsedMs: timestamp,
|
||||||
|
potionInventory: result.newInventory!,
|
||||||
|
events: [
|
||||||
|
CombatEvent.playerPotion(
|
||||||
|
timestamp: timestamp,
|
||||||
|
potionName: mpPotion.name,
|
||||||
|
healAmount: result.healedAmount,
|
||||||
|
isHp: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 몬스터 공격 처리
|
||||||
|
({CombatStats playerStats, int totalDamageTaken, List<CombatEvent> events})
|
||||||
|
_processMonsterAttack({
|
||||||
|
required CombatStats playerStats,
|
||||||
|
required MonsterCombatStats monsterStats,
|
||||||
|
required List<ActiveBuff> activeDebuffs,
|
||||||
|
required int totalDamageTaken,
|
||||||
|
required int timestamp,
|
||||||
|
required CombatCalculator calculator,
|
||||||
|
}) {
|
||||||
|
final events = <CombatEvent>[];
|
||||||
|
|
||||||
|
// 디버프 효과 적용된 몬스터 스탯 계산
|
||||||
|
var debuffedMonster = monsterStats;
|
||||||
|
if (activeDebuffs.isNotEmpty) {
|
||||||
|
double atkMod = 0;
|
||||||
|
for (final debuff in activeDebuffs) {
|
||||||
|
if (!debuff.isExpired(timestamp)) {
|
||||||
|
atkMod += debuff.effect.atkModifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ATK 감소 적용 (최소 10% ATK 유지)
|
||||||
|
final newAtk = (monsterStats.atk * (1 + atkMod)).round().clamp(
|
||||||
|
monsterStats.atk ~/ 10,
|
||||||
|
monsterStats.atk,
|
||||||
|
);
|
||||||
|
debuffedMonster = monsterStats.copyWith(atk: newAtk);
|
||||||
|
}
|
||||||
|
|
||||||
|
final attackResult = calculator.monsterAttackPlayer(
|
||||||
|
attacker: debuffedMonster,
|
||||||
|
defender: playerStats,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = attackResult.result;
|
||||||
|
if (result.isEvaded) {
|
||||||
|
events.add(
|
||||||
|
CombatEvent.playerEvade(
|
||||||
|
timestamp: timestamp,
|
||||||
|
attackerName: monsterStats.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (result.isBlocked) {
|
||||||
|
events.add(
|
||||||
|
CombatEvent.playerBlock(
|
||||||
|
timestamp: timestamp,
|
||||||
|
reducedDamage: result.damage,
|
||||||
|
attackerName: monsterStats.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (result.isParried) {
|
||||||
|
events.add(
|
||||||
|
CombatEvent.playerParry(
|
||||||
|
timestamp: timestamp,
|
||||||
|
reducedDamage: result.damage,
|
||||||
|
attackerName: monsterStats.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
events.add(
|
||||||
|
CombatEvent.monsterAttack(
|
||||||
|
timestamp: timestamp,
|
||||||
|
damage: result.damage,
|
||||||
|
attackerName: monsterStats.name,
|
||||||
|
attackDelayMs: monsterStats.attackDelayMs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
playerStats: attackResult.updatedDefender,
|
||||||
|
totalDamageTaken: totalDamageTaken + attackResult.result.damage,
|
||||||
|
events: events,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
172
lib/src/core/engine/death_handler.dart
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||||
|
|
||||||
|
/// 플레이어 사망 처리 서비스
|
||||||
|
///
|
||||||
|
/// ProgressService에서 분리된 사망 관련 로직 담당:
|
||||||
|
/// - 장비 손실 계산
|
||||||
|
/// - 사망 정보 기록
|
||||||
|
/// - 보스전 레벨링 모드 진입
|
||||||
|
class DeathHandler {
|
||||||
|
const DeathHandler();
|
||||||
|
|
||||||
|
/// 플레이어 사망 처리 (Phase 4)
|
||||||
|
///
|
||||||
|
/// 모든 장비 상실 및 사망 정보 기록.
|
||||||
|
/// 보스전 사망 시: 장비 보호 + 5분 레벨링 모드 진입.
|
||||||
|
GameState processPlayerDeath(
|
||||||
|
GameState state, {
|
||||||
|
required String killerName,
|
||||||
|
required DeathCause cause,
|
||||||
|
}) {
|
||||||
|
// 사망 직전 전투 이벤트 저장 (최대 10개)
|
||||||
|
final lastCombatEvents =
|
||||||
|
state.progress.currentCombat?.recentEvents ?? const [];
|
||||||
|
|
||||||
|
// 보스전 사망 여부 확인 (최종 보스 fighting 상태)
|
||||||
|
final isBossDeath =
|
||||||
|
state.progress.finalBossState == FinalBossState.fighting;
|
||||||
|
|
||||||
|
// 보스전 사망이 아닐 경우에만 장비 손실
|
||||||
|
var newEquipment = state.equipment;
|
||||||
|
var lostCount = 0;
|
||||||
|
String? lostItemName;
|
||||||
|
EquipmentSlot? lostItemSlot;
|
||||||
|
ItemRarity? lostItemRarity;
|
||||||
|
EquipmentItem? lostEquipmentItem; // 광고 부활 시 복구용
|
||||||
|
|
||||||
|
if (!isBossDeath) {
|
||||||
|
final lossResult = _calculateEquipmentLoss(state);
|
||||||
|
newEquipment = lossResult.equipment;
|
||||||
|
lostCount = lossResult.lostCount;
|
||||||
|
lostItemName = lossResult.lostItemName;
|
||||||
|
lostItemSlot = lossResult.lostItemSlot;
|
||||||
|
lostItemRarity = lossResult.lostItemRarity;
|
||||||
|
lostEquipmentItem = lossResult.lostItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사망 정보 생성 (전투 로그 포함)
|
||||||
|
final deathInfo = DeathInfo(
|
||||||
|
cause: cause,
|
||||||
|
killerName: killerName,
|
||||||
|
lostEquipmentCount: lostCount,
|
||||||
|
lostItemName: lostItemName,
|
||||||
|
lostItemSlot: lostItemSlot,
|
||||||
|
lostItemRarity: lostItemRarity,
|
||||||
|
lostItem: lostEquipmentItem,
|
||||||
|
goldAtDeath: state.inventory.gold,
|
||||||
|
levelAtDeath: state.traits.level,
|
||||||
|
timestamp: state.skillSystem.elapsedMs,
|
||||||
|
lastCombatEvents: lastCombatEvents,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 보스전 사망 시 5분 레벨링 모드 진입
|
||||||
|
final bossLevelingEndTime = isBossDeath
|
||||||
|
? DateTime.now().millisecondsSinceEpoch +
|
||||||
|
(5 * 60 * 1000) // 5분
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 전투 상태 초기화 및 사망 횟수 증가
|
||||||
|
final progress = state.progress.copyWith(
|
||||||
|
currentCombat: null,
|
||||||
|
deathCount: state.progress.deathCount + 1,
|
||||||
|
bossLevelingEndTime: bossLevelingEndTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
return state.copyWith(
|
||||||
|
equipment: newEquipment,
|
||||||
|
progress: progress,
|
||||||
|
deathInfo: deathInfo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 장비 손실 계산
|
||||||
|
({
|
||||||
|
Equipment equipment,
|
||||||
|
int lostCount,
|
||||||
|
String? lostItemName,
|
||||||
|
EquipmentSlot? lostItemSlot,
|
||||||
|
ItemRarity? lostItemRarity,
|
||||||
|
EquipmentItem? lostItem,
|
||||||
|
})
|
||||||
|
_calculateEquipmentLoss(GameState state) {
|
||||||
|
var newEquipment = state.equipment;
|
||||||
|
|
||||||
|
// 레벨 기반 장비 손실 확률 계산
|
||||||
|
// Lv 1: 20%, Lv 5: ~56%, Lv 10+: 100%
|
||||||
|
// 공식: 20 + (level - 1) * 80 / 9
|
||||||
|
final level = state.traits.level;
|
||||||
|
final lossChancePercent = level >= 10
|
||||||
|
? 100
|
||||||
|
: (20 + ((level - 1) * 80 ~/ 9)).clamp(0, 100);
|
||||||
|
final roll = state.rng.nextInt(100); // 0~99
|
||||||
|
final shouldLoseEquipment = roll < lossChancePercent;
|
||||||
|
|
||||||
|
// ignore: avoid_print
|
||||||
|
print(
|
||||||
|
'[Death] Lv$level lossChance=$lossChancePercent% roll=$roll '
|
||||||
|
'shouldLose=$shouldLoseEquipment',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shouldLoseEquipment) {
|
||||||
|
return (
|
||||||
|
equipment: newEquipment,
|
||||||
|
lostCount: 0,
|
||||||
|
lostItemName: null,
|
||||||
|
lostItemSlot: null,
|
||||||
|
lostItemRarity: null,
|
||||||
|
lostItem: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
|
||||||
|
final equippedNonWeaponSlots = <int>[];
|
||||||
|
for (var i = 1; i < Equipment.slotCount; i++) {
|
||||||
|
final item = state.equipment.getItemByIndex(i);
|
||||||
|
if (item.isNotEmpty) {
|
||||||
|
equippedNonWeaponSlots.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (equippedNonWeaponSlots.isEmpty) {
|
||||||
|
return (
|
||||||
|
equipment: newEquipment,
|
||||||
|
lostCount: 0,
|
||||||
|
lostItemName: null,
|
||||||
|
lostItemSlot: null,
|
||||||
|
lostItemRarity: null,
|
||||||
|
lostItem: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 랜덤하게 1개 슬롯 선택
|
||||||
|
final sacrificeIndex =
|
||||||
|
equippedNonWeaponSlots[state.rng.nextInt(
|
||||||
|
equippedNonWeaponSlots.length,
|
||||||
|
)];
|
||||||
|
|
||||||
|
// 제물로 바칠 아이템 정보 저장
|
||||||
|
final lostItem = state.equipment.getItemByIndex(sacrificeIndex);
|
||||||
|
final lostItemSlot = EquipmentSlot.values[sacrificeIndex];
|
||||||
|
|
||||||
|
// 해당 슬롯을 빈 장비로 교체
|
||||||
|
newEquipment = newEquipment.setItemByIndex(
|
||||||
|
sacrificeIndex,
|
||||||
|
EquipmentItem.empty(lostItemSlot),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[Death] Lost item: ${lostItem.name} (slot: $lostItemSlot)');
|
||||||
|
|
||||||
|
return (
|
||||||
|
equipment: newEquipment,
|
||||||
|
lostCount: 1,
|
||||||
|
lostItemName: lostItem.name,
|
||||||
|
lostItemSlot: lostItemSlot,
|
||||||
|
lostItemRarity: lostItem.rarity,
|
||||||
|
lostItem: lostItem,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||