Compare commits

...

4 Commits

Author SHA1 Message Date
JiWoong Sul
d07a0c5554 style: dart format 적용
- 전체 Dart 소스 및 테스트 파일 포매팅 통일
- trailing comma, 줄바꿈, 인덴트 정리
2026-02-13 16:08:23 +09:00
JiWoong Sul
bccb5cb188 docs: 개발 계획 및 감사 보고서 추가
- PLAN.md: 개발 계획 문서
- doc/audit-report-2026-02-13.md: 코드 감사 보고서
2026-02-13 16:08:18 +09:00
JiWoong Sul
6994f4fc9b chore(assets): 앱 아이콘 추가
- 512x512 PNG 아이콘 파일 추가
2026-02-13 16:08:14 +09:00
JiWoong Sul
ea64571eed chore(build): 번들 ID 변경 및 버전 업데이트
- com.example.asciineverdie → com.naturebridgeai.asciineverdie
- 버전 1.0.0+1 → 1.0.1+2
- iOS, macOS, Linux 빌드 설정 일괄 반영
2026-02-13 16:08:11 +09:00
50 changed files with 1262 additions and 434 deletions

250
PLAN.md Normal file
View 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()` 메서드 삭제 (사용되지 않음)

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

View File

@@ -0,0 +1,540 @@
# ASCII Never Die - 프로젝트 종합 감사 리포트
> 감사일: 2026-02-13
> 검사 수행: 7개 전문 에이전트 병렬 검사
> 대상: 코드 품질, 빌드/테스트, 출시 준비, 사업/수익화, 보안, 로컬라이제이션/접근성, 원본 충실도
---
## 0. 전체 요약 대시보드
| 영역 | 점수 | CRITICAL | HIGH | MEDIUM | LOW |
|------|------|----------|------|--------|-----|
| 보안 | **4/10** | 2 | 1 | 1 | - |
| 출시 준비 | **3/10** | 7 | 4 | 5 | - |
| 사업/수익화 | **4/10** | 5 | 1 | 1 | 1 |
| 코드 품질 | **7/10** | - | 3 | 3 | 1 |
| 빌드/테스트 | **7/10** | - | 1 | 2 | - |
| 로컬라이제이션 | **5/10** | 5 | 3 | 4 | - |
| 원본 충실도 | **특수** | 1 | - | - | - |
**종합 판정: 출시 불가 상태. CRITICAL 이슈 20건 해결 필요.**
---
## 1. 보안 - CRITICAL 이슈 발견
### 1.1 CRITICAL
| # | 이슈 | 위치 | 영향 |
|---|------|------|------|
| S1 | **JKS 키스토어가 Git에 추적 중** | `doc/key/askiineverdie.jks` | 앱 위조 서명 가능, 저장소 접근자 전원 노출 |
| S2 | **key.properties 평문 비밀번호 Git 노출** | `android/key.properties` (storePassword=askiineverdie) | 키스토어 비밀번호 완전 노출 |
### 1.2 즉시 조치 방법
```bash
# 1. .gitignore에 추가
*.jks
*.keystore
android/key.properties
doc/key/
*.env
# 2. Git 추적 해제
git rm --cached doc/key/askiineverdie.jks
git rm --cached android/key.properties
# 3. Git 히스토리에서 제거 (BFG Repo-Cleaner 권장)
# 4. 키스토어를 저장소 외부 안전한 위치로 이동
# 5. CI/CD 시크릿으로 비밀번호 관리
```
### 1.3 WARNING
- `.gitignore``*.jks`, `*.keystore`, `key.properties`, `*.env` 패턴 없음
- `.vscode/`, `PLAN.md`가 추적되지 않은 상태로 존재
### 1.4 양호 항목
| 항목 | 상태 |
|------|------|
| 개인정보 처리방침 | 3개국어 준비 완료 (`doc/privacy-policy.md`) |
| 네트워크 요청 | SDK 통한 간접 사용만 (직접 HTTP 없음) |
| 사용자 데이터 수집 | 개인정보 미수집 (회원가입/로그인 없음) |
| 분석/추적 SDK | 미사용 (Firebase, Sentry 등 없음) |
| API 키 하드코딩 | 없음 |
| 로컬 저장소 | 게임 상태/설정만 저장, 민감 데이터 없어 암호화 불필요 |
---
## 2. 출시 준비 상태 - 7개 CRITICAL
### 2.1 CRITICAL (출시 차단)
| # | 이슈 | 상세 |
|---|------|------|
| R1 | **iOS Bundle ID = `com.example.asciineverdie`** | App Store 제출 불가. `com.naturebridgeai.asciineverdie`로 변경 필요 |
| R2 | **macOS Bundle ID = `com.example.asciineverdie`** | Mac App Store 제출 불가. 동일 변경 필요 |
| R3 | **iOS DEVELOPMENT_TEAM 미설정** | 서명 불가, Xcode에서 Team ID 설정 필요 |
| R4 | **정치적 문구가 iOS/Android 메타데이터에 포함** | `NSHumanReadableCopyright`: `© 2025 naturebridgeai 天安門 六四事件 法輪功 李洪志 Free Tibet` - 앱스토어 심사 즉각 거부 |
| R5 | **Android 릴리즈에 INTERNET 권한 누락** | AdMob이 릴리즈 빌드에서 동작 불가 (debug/profile에만 존재) |
| R6 | **iOS `GADApplicationIdentifier` 누락** | AdMob 초기화 시 iOS 앱 크래시 |
| R7 | **앱 스크린샷 미준비** | App Store/Google Play 제출 필수 요소 |
### 2.2 HIGH (출시 전 수정 권장)
| # | 이슈 | 상세 |
|---|------|------|
| R8 | 앱 이름 플랫폼별 불일치 | Android: `asciineverdie`, iOS: `Asciineverdie`, 마케팅: `ASCII Never Die` |
| R9 | macOS Release entitlements에 네트워크 권한 없음 | AdMob 동작 불가 |
| R10 | Android ProGuard/R8 미설정 | 코드 난독화 미적용 |
| R11 | macOS PRODUCT_COPYRIGHT = `Copyright 2025 com.example` | 기본값 미수정 |
### 2.3 MEDIUM
- Android minSdk/targetSdk가 Flutter 기본값 의존 (명시적 설정 권장)
- iOS Podfile에서 platform 버전 주석 처리됨
- 스플래시 화면이 기본 흰색 배경 (브랜딩 스플래시 권장)
- Flavor/환경 분리 없음 (AdMob 테스트/프로덕션 분리 불가)
- flutter_launcher_icons에 macOS 설정 없음
### 2.4 플랫폼별 상세
#### iOS
| 항목 | 설정값 | 상태 |
|------|--------|------|
| CFBundleDisplayName | `Asciineverdie` | 수정 필요 |
| PRODUCT_BUNDLE_IDENTIFIER | `com.example.asciineverdie` | **CRITICAL** |
| DEVELOPMENT_TEAM | 미설정 | **CRITICAL** |
| IPHONEOS_DEPLOYMENT_TARGET | `13.0` | OK |
| 앱 아이콘 | 전 사이즈 존재 (20~1024px) | OK |
| LaunchScreen | 기본 Flutter 템플릿 | 개선 권장 |
#### Android
| 항목 | 설정값 | 상태 |
|------|--------|------|
| applicationId | `com.naturebridgeai.asciineverdie` | OK |
| 릴리즈 서명 | key.properties 참조 | OK |
| AdMob App ID | `ca-app-pub-6691216385521068~8216990571` | OK |
| 앱 아이콘 | mdpi~xxxhdpi + Adaptive Icon | OK |
| INTERNET 권한 | 릴리즈 미설정 | **CRITICAL** |
| ProGuard/R8 | 미설정 | HIGH |
#### macOS
| 항목 | 설정값 | 상태 |
|------|--------|------|
| PRODUCT_BUNDLE_IDENTIFIER | `com.example.asciineverdie` | **CRITICAL** |
| Sandbox | 활성화 | OK |
| 네트워크 권한 (Release) | 미설정 | HIGH |
| MACOSX_DEPLOYMENT_TARGET | `10.15` | OK |
| 앱 아이콘 | 16~1024px 존재 | OK |
---
## 3. 사업/수익화
### 3.1 현재 구현 상태
> **참고**: 사용자는 "IAP가 아직 설정이 안되어있다"고 인지하고 있으나, 실제로는 IAP와 AdMob 코드가 **이미 구현되어 있고 프로덕션 ID만 미설정** 상태임.
| 수익원 | 코드 구현 | 프로덕션 준비 | 준비도 |
|--------|----------|-------------|--------|
| 리워드 광고 (부활/되돌리기) | 구현됨 (`ad_service.dart`) | ID 미설정 | 60% |
| 인터스티셜 광고 (충전/속도업) | 구현됨 | ID 미설정 | 60% |
| 광고 제거 IAP ($9.99) | 구현됨 (`iap_service.dart`) | 스토어 상품 미등록 | 50% |
### 3.2 CRITICAL
| # | 이슈 |
|---|------|
| B1 | 프로덕션 광고 단위 ID가 모두 `ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX` 플레이스홀더 (`ad_service.dart:74-82`) |
| B2 | iOS AdMob Info.plist 설정 누락 (GADApplicationIdentifier, SKAdNetworkItems, NSUserTrackingUsageDescription) |
| B3 | IAP 스토어 상품 미등록 (Google Play Console / App Store Connect) |
| B4 | iOS StoreKit Configuration 파일 없음 (로컬 테스트 불가) |
| B5 | iOS/macOS Bundle ID가 `com.example` (스토어 연동 불가) |
### 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.example.asciineverdie` | **CRITICAL - 변경 필요** |
| macOS | `com.example.asciineverdie` | **CRITICAL - 변경 필요** |
---
## 4. 빌드/테스트/정적분석
### 4.1 실행 결과
| 단계 | 결과 | 상세 |
|------|------|------|
| `flutter pub get` | **통과** | 의존성 정상 설치, 31개 패키지 업데이트 가능 |
| `dart format --set-exit-if-changed .` | **실패** | 210개 중 **42개 파일** 포맷 미준수 (자동 수정됨) |
| `flutter analyze` | **통과** (info 56건) | error 0, warning 0, info 56 (모두 스타일 수준) |
| `flutter test` | **실패** (1건) | 104 통과 / **1 실패** |
### 4.2 포맷 미준수 주요 파일
- `lib/data/game_text_l10n.dart`
- `lib/src/core/engine/` 하위 다수 (act_progression_service, character_roll_service, chest_service, combat_tick_service 등)
- `lib/src/core/model/` 하위 (combat_stats, item_stats, monetization_state, potion, treasure_chest)
- `lib/src/features/game/` 하위 다수 (layouts, managers, pages, widgets)
- `lib/src/features/new_character/` 하위
- `test/` 하위 4개 파일
### 4.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`
- **테스트**: `SkillService useBuffSkill 버프 적용`
- **Expected**: `0.25`, **Actual**: `0.15`
- **원인**: 버프 스킬 적용 비율 값 불일치
---
## 5. 코드 품질
### 5.1 Clean Architecture 위반 (MEDIUM)
`core/` 레이어에 Flutter UI 의존성 존재 (Domain은 프레임워크 무관해야 함):
| 파일 | 문제 |
|------|------|
| `core/constants/ascii_colors.dart:1` | `import 'package:flutter/material.dart'` + `BuildContext` 파라미터 |
| `core/l10n/game_data_l10n.dart:5` | `import 'package:flutter/widgets.dart'` + `BuildContext` 사용 |
| `core/animation/canvas/ascii_canvas_painter.dart:1` | `import 'package:flutter/material.dart'` |
| `core/animation/canvas/ascii_canvas_widget.dart:1` | `import 'package:flutter/material.dart'` |
| `core/animation/ascii_animation_data.dart:1` | `import 'package:flutter/material.dart'` |
**권장**: `core/animation/`, `core/constants/`, `core/l10n/` 일부를 `shared/` 또는 `features/`로 이동
**양호**: `core/engine/`, `core/model/`, `core/util/` 등 핵심 도메인 로직은 순수 Dart로 작성
### 5.2 SRP 위반 - 대형 파일 (HIGH)
| 파일 | LOC | 권장 |
|------|-----|------|
| `features/game/game_play_screen.dart` | **1,536** | 위젯별 분리 |
| `core/animation/canvas/canvas_battle_composer.dart` | **1,475** | 렌더링 단계별 분리 |
| `core/engine/progress_service.dart` | **1,247** | 기능별 서비스 추출 |
| `features/arena/arena_battle_screen.dart` | **976** | 위젯 분리 |
| `features/game/widgets/enhanced_animation_panel.dart` | **877** | 분리 |
| `features/settings/settings_screen.dart` | **821** | 섹션별 분리 |
| `core/engine/arena_service.dart` | **811** | 분리 |
| `features/game/widgets/death_overlay.dart` | **795** | 분리 |
| `core/engine/skill_service.dart` | **759** | 분리 |
| `app.dart` | **723** | 라우팅/테마/설정 분리 |
| `core/engine/combat_tick_service.dart` | **681** | 분리 |
| `core/model/game_statistics.dart` | **616** | 분리 |
*참고: 정적 데이터 파일 (game_translations_ko/ja.dart, pq_config_data.dart 등)은 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 코드 중복 (MEDIUM)
**`_toRoman()` 함수 3곳 중복** (유틸 `intToRoman()` 존재):
- `core/util/roman.dart` - `intToRoman()` (원본 유틸)
- `features/game/game_play_screen.dart:1443` - `_toRoman()` (중복)
- `features/game/pages/story_page.dart:117` - `_toRoman()` (중복)
### 5.6 TODO/FIXME 미완성 마커
| 위치 | 내용 |
|------|------|
| `core/engine/iap_service.dart:15` | `TODO: Google Play Console / App Store Connect에서 상품 생성 후 ID 교체` |
| `core/engine/ad_service.dart:74` | `TODO: AdMob 콘솔에서 광고 단위 생성 후 아래 ID 교체` |
| `ad_service.dart:76,78,80,82` | 프로덕션 광고 ID 모두 플레이스홀더 |
### 5.7 싱글톤 패턴 과다 사용 (LOW)
6개 서비스가 싱글톤: `AdService`, `IAPService`, `DebugSettingsService`, `ReturnRewardsService`, `CharacterRollService`, `AudioService`
테스트 가능성(testability) 저하. DI(의존성 주입) 패턴으로 전환 권장.
### 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%+ 미번역** | 약 60~70개 키가 영어 그대로 (tagNoNetwork, newCharacter, cancel, exitGame, characterSheet, traits, stats, equipment, inventory, 모든 equip*, stat*, menu*, options*, sound* 등) |
| L3 | **Arena 관련 화면 전체 영어 하드코딩** | `MY EQUIPMENT`, `ENEMY EQUIPMENT`, `ARENA BATTLE`, `START BATTLE`, `WINNER`, `LOSER` 등 ARB 미사용 |
| L4 | **statistics_dialog.dart 하드코딩** | 30개+ 텍스트가 `isKorean ? '한국어' : isJapanese ? '日本語' : 'English'` 삼항 연산자로 직접 처리 |
| L5 | **iOS `CFBundleLocalizations` 미설정** | iOS에서 앱 언어 인식 불가 |
### 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에 명시된 규칙:
> "원본 알고리즘과 데이터를 그대로 유지해야 합니다"
> "example/pq/ 내 Delphi 소스의 알고리즘/데이터를 100% 동일하게 포팅"
> "새로운 기능, 값, 처리 로직 추가 금지"
현재 구현은 이 규칙과 **상당히 괴리**가 있음.
**권장: CLAUDE.md를 현재 프로젝트 실태에 맞게 업데이트하거나, 원본 충실도 방향을 재정립할 필요가 있음.**
---
## 8. 우선순위별 액션 플랜
### P0 - 즉시 (보안/심사 차단)
| # | 작업 | 난이도 | 예상 시간 |
|---|------|--------|----------|
| 1 | Git에서 JKS 키스토어 + key.properties 제거 | 낮음 | 30분 |
| 2 | .gitignore에 민감 파일 패턴 추가 | 낮음 | 10분 |
| 3 | 정치적 문구 제거 (iOS/Android 모두) | 낮음 | 10분 |
| 4 | iOS/macOS Bundle ID 변경 (`com.example` -> `com.naturebridgeai.asciineverdie`) | 낮음 | 20분 |
### P1 - 출시 전 필수
| # | 작업 | 난이도 | 예상 시간 |
|---|------|--------|----------|
| 5 | iOS DEVELOPMENT_TEAM 설정 | 낮음 | 10분 |
| 6 | Android 릴리즈 INTERNET 권한 추가 | 낮음 | 5분 |
| 7 | iOS GADApplicationIdentifier + SKAdNetworkItems + ATT 추가 | 중간 | 1시간 |
| 8 | macOS Release entitlements 네트워크 권한 추가 | 낮음 | 10분 |
| 9 | 앱 이름 통일 (`ASCII Never Die`) - 모든 플랫폼 | 낮음 | 30분 |
| 10 | AdMob 프로덕션 광고 단위 ID 설정 | 중간 | AdMob 콘솔 작업 |
| 11 | IAP 스토어 상품 등록 (Google Play / App Store Connect) | 중간 | 스토어 콘솔 작업 |
| 12 | 앱 스크린샷 제작 (각 플랫폼/언어별) | 중간 | 2~4시간 |
| 13 | 일본어 ARB 번역 완성 (~70개 키) | 중간 | 2~3시간 |
| 14 | iOS CFBundleLocalizations 설정 | 낮음 | 10분 |
| 15 | `dart format .` 적용 | 낮음 | 5분 |
| 16 | 실패 테스트 수정 (`skill_service_test.dart:563`) | 낮음 | 30분 |
| 17 | macOS PRODUCT_COPYRIGHT 수정 | 낮음 | 5분 |
### P2 - 출시 후 개선
| # | 작업 | 난이도 |
|---|------|--------|
| 18 | 하드코딩 문자열 ARB 키 전환 (arena, statistics, notification 등) | 높음 |
| 19 | 대형 파일 분리 (game_play_screen, progress_service 등 12개 파일) | 높음 |
| 20 | 대형 함수 리팩토링 (_showOptionsMenu 263줄 등 11개 함수) | 높음 |
| 21 | Clean Architecture 위반 정리 (core/animation, core/constants -> shared/) | 중간 |
| 22 | Android ProGuard/R8 설정 | 중간 |
| 23 | 스플래시 화면 커스텀 (flutter_native_splash) | 낮음 |
| 24 | 접근성 개선 (Semantics, 텍스트 크기 대응, 색상 대비) | 높음 |
| 25 | 싱글톤 -> DI 패턴 전환 (6개 서비스) | 높음 |
| 26 | 코드 중복 제거 (_toRoman 등) | 낮음 |
| 27 | CLAUDE.md 현행화 (원본 충실도 방향 재정립) | 낮음 |
| 28 | IAP 가격 조정 검토 ($9.99 -> $2.99~$4.99) | 결정 사항 |
| 29 | Crashlytics/분석 도구 도입 (출시 후 모니터링) | 중간 |
| 30 | 키보드 네비게이션 강화 (macOS 빌드) | 중간 |
---
## 9. 종합 평가
### 잘된 점
- 핵심 게임 로직(PQ 알고리즘) 포팅 품질 우수
- 독자적 세계관("디지털 판타지")으로의 창의적 재해석
- 전투/스킬/보스 등 풍부한 확장 시스템 (13개 신규 시스템)
- 개인정보 처리방침 3개국어 준비 완료
- 앱 아이콘 전 플랫폼 생성 완료 (iOS/Android/macOS)
- 네이밍 컨벤션 및 코드 구조 양호
- 보안: 네트워크 직접 사용 없음, API 키 하드코딩 없음
### 즉시 해결 필요
- **보안**: 키스토어/비밀번호 Git 노출 (가장 시급)
- **출시 차단**: 정치적 문구, `com.example` Bundle ID, 누락된 플랫폼 설정
- **수익화**: 프로덕션 ID 미설정, 스토어 상품 미등록
### 전략적 결정 필요
- CLAUDE.md의 "100% 동일 포팅" 목표 vs 현재 "스핀오프/리메이크" 실태 정립
- 원작이 무료인 점을 감안한 수익 모델 최적화
- 광고 제거 IAP 가격 결정 ($9.99 vs $2.99~$4.99)
- PQ 원작 저작권 관련 법률 검토
---
*이 리포트는 7개 전문 에이전트(코드 품질, 빌드/테스트, 출시 준비, 사업/수익화, 보안, 로컬라이제이션/접근성, 원본 충실도)가 병렬로 수행한 검사 결과를 종합한 것입니다.*

View File

@@ -368,7 +368,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.asciineverdie; 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 +384,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.asciineverdie.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 +401,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.asciineverdie.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 +416,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.asciineverdie.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";
@@ -547,7 +547,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.asciineverdie; 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";
@@ -569,7 +569,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.example.asciineverdie; 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;

View File

@@ -172,8 +172,7 @@ String returnRewardHoursAway(String time) =>
String returnRewardChests(int count) => String returnRewardChests(int count) =>
_l('$count Treasure Chest(s)', '보물 상자 $count개', '宝箱 $count個'); _l('$count Treasure Chest(s)', '보물 상자 $count개', '宝箱 $count個');
String get returnRewardOpenChests => _l('Open Chests', '상자 열기', '宝箱を開ける'); String get returnRewardOpenChests => _l('Open Chests', '상자 열기', '宝箱を開ける');
String get returnRewardBonusChests => String get returnRewardBonusChests => _l('Bonus Chests', '보너스 상자', 'ボーナス宝箱');
_l('Bonus Chests', '보너스 상자', 'ボーナス宝箱');
String get returnRewardClaimBonus => String get returnRewardClaimBonus =>
_l('Get Bonus (AD)', '보너스 받기 (광고)', 'ボーナス受取 (広告)'); _l('Get Bonus (AD)', '보너스 받기 (광고)', 'ボーナス受取 (広告)');
String get returnRewardClaimBonusFree => String get returnRewardClaimBonusFree =>
@@ -193,8 +192,7 @@ String chestRewardExpAmount(int exp) =>
_l('+$exp EXP', '+$exp 경험치', '+$exp 経験値'); _l('+$exp EXP', '+$exp 경험치', '+$exp 経験値');
String chestRewardPotionAmount(String name, int count) => String chestRewardPotionAmount(String name, int count) =>
_l('$name x$count', '$name x$count', '$name x$count'); _l('$name x$count', '$name x$count', '$name x$count');
String get chestRewardEquipped => String get chestRewardEquipped => _l('Equipped!', '장착됨!', '装備しました!');
_l('Equipped!', '장착됨!', '装備しました!');
String get chestRewardBetterItem => String get chestRewardBetterItem =>
_l('Better than current!', '현재보다 좋습니다!', '現在より良い!'); _l('Better than current!', '현재보다 좋습니다!', '現在より良い!');
@@ -1143,24 +1141,15 @@ String get uiSaveBattleLog => _l('Save Battle Log', '배틀로그 저장', 'バ
String get iapRemoveAds => _l('Remove Ads', '광고 제거', '広告削除'); String get iapRemoveAds => _l('Remove Ads', '광고 제거', '広告削除');
String get iapRemoveAdsDesc => String get iapRemoveAdsDesc =>
_l('Enjoy ad-free experience', '광고 없이 플레이', '広告なしでプレイ'); _l('Enjoy ad-free experience', '광고 없이 플레이', '広告なしでプレイ');
String get iapBenefitTitle => String get iapBenefitTitle => _l('Premium Benefits', '프리미엄 혜택', 'プレミアム特典');
_l('Premium Benefits', '프리미엄 혜택', 'プレミアム特典'); String get iapBenefit1 => _l('Ad-free gameplay', '광고 없는 쾌적한 플레이', '広告なしの快適プレイ');
String get iapBenefit1 =>
_l('Ad-free gameplay', '광고 없는 쾌적한 플레이', '広告なしの快適プレイ');
String get iapBenefit2 => String get iapBenefit2 =>
_l('Unlimited speed boost', '속도 부스트 무제한', 'スピードブースト無制限'); _l('Unlimited speed boost', '속도 부스트 무제한', 'スピードブースト無制限');
String get iapBenefit3 => _l( String get iapBenefit3 =>
'Stat reroll undo: 3 times', _l('Stat reroll undo: 3 times', '신규 캐릭터 스탯 가챠 되돌리기 3회', '新キャラステ振り直し3回');
'신규 캐릭터 스탯 가챠 되돌리기 3회', String get iapBenefit4 => _l('Unlimited rerolls', '굴리기 무제한', 'リロール無制限');
'新キャラステ振り直し3回', String get iapBenefit5 =>
); _l('2x offline time credited', '오프라인 시간 2배 인정', 'オフライン時間2倍適用');
String get iapBenefit4 =>
_l('Unlimited rerolls', '굴리기 무제한', 'リロール無制限');
String get iapBenefit5 => _l(
'2x offline time credited',
'오프라인 시간 2배 인정',
'オフライン時間2倍適用',
);
String get iapBenefit6 => String get iapBenefit6 =>
_l('Return chests: 10 max', '복귀 상자 최대 10개', '帰還ボックス最大10個'); _l('Return chests: 10 max', '복귀 상자 최대 10개', '帰還ボックス最大10個');
String get iapPurchaseButton => _l('Purchase', '구매하기', '購入する'); String get iapPurchaseButton => _l('Purchase', '구매하기', '購入する');
@@ -1232,17 +1221,12 @@ String get skillNoDetails => _l('No details', '상세 정보 없음', '詳細情
// ============================================================================ // ============================================================================
String get notifyLevelUp => _l('LEVEL UP!', '레벨 업!', 'レベルアップ!'); String get notifyLevelUp => _l('LEVEL UP!', '레벨 업!', 'レベルアップ!');
String notifyLevel(int level) => String notifyLevel(int level) => _l('Level $level', '레벨 $level', 'レベル $level');
_l('Level $level', '레벨 $level', 'レベル $level'); String get notifyQuestComplete => _l('QUEST COMPLETE!', '퀘스트 완료!', 'クエスト完了!');
String get notifyQuestComplete =>
_l('QUEST COMPLETE!', '퀘스트 완료!', 'クエスト完了!');
String get notifyPrologueComplete => String get notifyPrologueComplete =>
_l('PROLOGUE COMPLETE!', '프롤로그 완료!', 'プロローグ完了!'); _l('PROLOGUE COMPLETE!', '프롤로그 완료!', 'プロローグ完了!');
String notifyActComplete(int actNumber) => _l( String notifyActComplete(int actNumber) =>
'ACT $actNumber COMPLETE!', _l('ACT $actNumber COMPLETE!', '${actNumber}막 완료!', '${actNumber}幕完了!');
'${actNumber}막 완료!',
'${actNumber}幕完了!',
);
String get notifyNewSpell => _l('NEW SPELL!', '새 주문!', '新しい呪文!'); String get notifyNewSpell => _l('NEW SPELL!', '새 주문!', '新しい呪文!');
String get notifyNewEquipment => _l('NEW EQUIPMENT!', '새 장비!', '新しい装備!'); String get notifyNewEquipment => _l('NEW EQUIPMENT!', '새 장비!', '新しい装備!');
String get notifyBossDefeated => _l('BOSS DEFEATED!', '보스 처치!', 'ボス撃破!'); String get notifyBossDefeated => _l('BOSS DEFEATED!', '보스 처치!', 'ボス撃破!');

View File

@@ -16,9 +16,7 @@ import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
/// ///
/// ProgressService에서 추출된 Act 완료, 보스 생성 등의 로직 담당. /// ProgressService에서 추출된 Act 완료, 보스 생성 등의 로직 담당.
class ActProgressionService { class ActProgressionService {
const ActProgressionService({ const ActProgressionService({required this.config});
required this.config,
});
final PqConfig config; final PqConfig config;

View File

@@ -64,8 +64,10 @@ class CharacterRollService {
_resetUndoForNewSession(); _resetUndoForNewSession();
_isInitialized = true; _isInitialized = true;
debugPrint('[CharacterRollService] Initialized: ' debugPrint(
'rolls=$_rollsRemaining, undo=$_undoRemaining'); '[CharacterRollService] Initialized: '
'rolls=$_rollsRemaining, undo=$_undoRemaining',
);
} }
/// 저장된 상태 로드 /// 저장된 상태 로드
@@ -148,8 +150,10 @@ class CharacterRollService {
// - 무료 유저: 1회 (광고 시청 필요) // - 무료 유저: 1회 (광고 시청 필요)
_undoRemaining = _isPaidUser ? maxUndoPaidUser : maxUndoFreeUser; _undoRemaining = _isPaidUser ? maxUndoPaidUser : maxUndoFreeUser;
debugPrint('[CharacterRollService] Rolled: remaining=$_rollsRemaining, ' debugPrint(
'history=${_rollHistory.length}, undo=$_undoRemaining'); '[CharacterRollService] Rolled: remaining=$_rollsRemaining, '
'history=${_rollHistory.length}, undo=$_undoRemaining',
);
return true; return true;
} }
@@ -214,8 +218,10 @@ class CharacterRollService {
final snapshot = _rollHistory.removeAt(0); final snapshot = _rollHistory.removeAt(0);
_undoRemaining--; _undoRemaining--;
debugPrint('[CharacterRollService] Undo (paid): ' debugPrint(
'remaining=$_undoRemaining, history=${_rollHistory.length}'); '[CharacterRollService] Undo (paid): '
'remaining=$_undoRemaining, history=${_rollHistory.length}',
);
return snapshot; return snapshot;
} }
@@ -241,8 +247,10 @@ class CharacterRollService {
); );
if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) { if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) {
debugPrint('[CharacterRollService] Undo (free with ad): ' debugPrint(
'remaining=$_undoRemaining, history=${_rollHistory.length}'); '[CharacterRollService] Undo (free with ad): '
'remaining=$_undoRemaining, history=${_rollHistory.length}',
);
return result; return result;
} }

View File

@@ -12,7 +12,7 @@ import 'package:asciineverdie/src/core/util/deterministic_random.dart';
/// 상자 내용물 생성 및 오픈 로직 담당 /// 상자 내용물 생성 및 오픈 로직 담당
class ChestService { class ChestService {
ChestService({DeterministicRandom? rng}) ChestService({DeterministicRandom? rng})
: _rng = rng ?? DeterministicRandom(DateTime.now().millisecondsSinceEpoch); : _rng = rng ?? DeterministicRandom(DateTime.now().millisecondsSinceEpoch);
final DeterministicRandom _rng; final DeterministicRandom _rng;
@@ -100,7 +100,9 @@ class ChestService {
rarity: rarity, rarity: rarity,
); );
debugPrint('[ChestService] Equipment reward: ${item.name} (${rarity.name})'); debugPrint(
'[ChestService] Equipment reward: ${item.name} (${rarity.name})',
);
return ChestReward.equipment(item); return ChestReward.equipment(item);
} }
@@ -132,7 +134,10 @@ class ChestService {
ChestReward _generateGoldReward(int playerLevel) { ChestReward _generateGoldReward(int playerLevel) {
final baseGold = playerLevel * _goldPerLevel; final baseGold = playerLevel * _goldPerLevel;
final variance = _rng.nextInt(_goldVariance * 2 + 1) - _goldVariance; final variance = _rng.nextInt(_goldVariance * 2 + 1) - _goldVariance;
final gold = (baseGold + (baseGold * variance / 100)).round().clamp(10, 99999); final gold = (baseGold + (baseGold * variance / 100)).round().clamp(
10,
99999,
);
debugPrint('[ChestService] Gold reward: $gold'); debugPrint('[ChestService] Gold reward: $gold');
return ChestReward.gold(gold); return ChestReward.gold(gold);
@@ -142,7 +147,10 @@ class ChestService {
ChestReward _generateExperienceReward(int playerLevel) { ChestReward _generateExperienceReward(int playerLevel) {
final baseExp = playerLevel * _expPerLevel; final baseExp = playerLevel * _expPerLevel;
final variance = _rng.nextInt(_expVariance * 2 + 1) - _expVariance; final variance = _rng.nextInt(_expVariance * 2 + 1) - _expVariance;
final exp = (baseExp + (baseExp * variance / 100)).round().clamp(10, 999999); final exp = (baseExp + (baseExp * variance / 100)).round().clamp(
10,
999999,
);
debugPrint('[ChestService] Experience reward: $exp'); debugPrint('[ChestService] Experience reward: $exp');
return ChestReward.experience(exp); return ChestReward.experience(exp);
@@ -208,49 +216,49 @@ class ChestService {
return switch (slot) { return switch (slot) {
EquipmentSlot.weapon => ItemStats( EquipmentSlot.weapon => ItemStats(
atk: baseValue * 2, atk: baseValue * 2,
criRate: 0.01 * (level ~/ 5), criRate: 0.01 * (level ~/ 5),
parryRate: 0.005 * level, parryRate: 0.005 * level,
), ),
EquipmentSlot.shield => ItemStats( EquipmentSlot.shield => ItemStats(
def: baseValue, def: baseValue,
blockRate: 0.02 * (level ~/ 3).clamp(1, 10), blockRate: 0.02 * (level ~/ 3).clamp(1, 10),
), ),
EquipmentSlot.helm => ItemStats( EquipmentSlot.helm => ItemStats(
def: baseValue ~/ 2, def: baseValue ~/ 2,
magDef: baseValue ~/ 2, magDef: baseValue ~/ 2,
intBonus: level ~/ 10, intBonus: level ~/ 10,
), ),
EquipmentSlot.hauberk => ItemStats(def: baseValue, hpBonus: level * 2), EquipmentSlot.hauberk => ItemStats(def: baseValue, hpBonus: level * 2),
EquipmentSlot.brassairts => ItemStats( EquipmentSlot.brassairts => ItemStats(
def: baseValue ~/ 2, def: baseValue ~/ 2,
strBonus: level ~/ 15, strBonus: level ~/ 15,
), ),
EquipmentSlot.vambraces => ItemStats( EquipmentSlot.vambraces => ItemStats(
def: baseValue ~/ 2, def: baseValue ~/ 2,
dexBonus: level ~/ 15, dexBonus: level ~/ 15,
), ),
EquipmentSlot.gauntlets => ItemStats( EquipmentSlot.gauntlets => ItemStats(
atk: baseValue ~/ 2, atk: baseValue ~/ 2,
def: baseValue ~/ 4, def: baseValue ~/ 4,
), ),
EquipmentSlot.gambeson => ItemStats( EquipmentSlot.gambeson => ItemStats(
def: baseValue ~/ 2, def: baseValue ~/ 2,
conBonus: level ~/ 15, conBonus: level ~/ 15,
), ),
EquipmentSlot.cuisses => ItemStats( EquipmentSlot.cuisses => ItemStats(
def: baseValue ~/ 2, def: baseValue ~/ 2,
evasion: 0.005 * level, evasion: 0.005 * level,
), ),
EquipmentSlot.greaves => ItemStats( EquipmentSlot.greaves => ItemStats(
def: baseValue ~/ 2, def: baseValue ~/ 2,
evasion: 0.003 * level, evasion: 0.003 * level,
), ),
EquipmentSlot.sollerets => ItemStats( EquipmentSlot.sollerets => ItemStats(
def: baseValue ~/ 3, def: baseValue ~/ 3,
evasion: 0.002 * level, evasion: 0.002 * level,
dexBonus: level ~/ 20, dexBonus: level ~/ 20,
), ),
}; };
} }

View File

@@ -210,7 +210,8 @@ class CombatTickService {
MonsterCombatStats monsterStats, MonsterCombatStats monsterStats,
int totalDamageDealt, int totalDamageDealt,
List<CombatEvent> events, List<CombatEvent> events,
}) _processDotTicks({ })
_processDotTicks({
required List<DotEffect> activeDoTs, required List<DotEffect> activeDoTs,
required MonsterCombatStats monsterStats, required MonsterCombatStats monsterStats,
required int elapsedMs, required int elapsedMs,
@@ -272,7 +273,8 @@ class CombatTickService {
int lastPotionUsedMs, int lastPotionUsedMs,
PotionInventory potionInventory, PotionInventory potionInventory,
List<CombatEvent> events, List<CombatEvent> events,
})? _tryEmergencyPotion({ })?
_tryEmergencyPotion({
required CombatStats playerStats, required CombatStats playerStats,
required PotionInventory potionInventory, required PotionInventory potionInventory,
required int lastPotionUsedMs, required int lastPotionUsedMs,
@@ -371,7 +373,8 @@ class CombatTickService {
int totalDamageDealt, int totalDamageDealt,
List<CombatEvent> events, List<CombatEvent> events,
bool isFirstPlayerAttack, bool isFirstPlayerAttack,
}) _processPlayerAttack({ })
_processPlayerAttack({
required GameState state, required GameState state,
required CombatStats playerStats, required CombatStats playerStats,
required MonsterCombatStats monsterStats, required MonsterCombatStats monsterStats,
@@ -508,10 +511,13 @@ class CombatTickService {
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown(); newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
if (skillResult.debuffEffect != null) { if (skillResult.debuffEffect != null) {
newActiveBuffs = newActiveBuffs newActiveBuffs =
.where((d) => d.effect.id != skillResult.debuffEffect!.effect.id) newActiveBuffs
.toList() .where(
..add(skillResult.debuffEffect!); (d) => d.effect.id != skillResult.debuffEffect!.effect.id,
)
.toList()
..add(skillResult.debuffEffect!);
} }
events.add( events.add(
@@ -601,11 +607,8 @@ class CombatTickService {
} }
/// 몬스터 공격 처리 /// 몬스터 공격 처리
({ ({CombatStats playerStats, int totalDamageTaken, List<CombatEvent> events})
CombatStats playerStats, _processMonsterAttack({
int totalDamageTaken,
List<CombatEvent> events,
}) _processMonsterAttack({
required CombatStats playerStats, required CombatStats playerStats,
required MonsterCombatStats monsterStats, required MonsterCombatStats monsterStats,
required List<ActiveBuff> activeDebuffs, required List<ActiveBuff> activeDebuffs,

View File

@@ -131,9 +131,7 @@ class IAPService {
final response = await _iap.queryProductDetails(IAPProductIds.all); final response = await _iap.queryProductDetails(IAPProductIds.all);
if (response.notFoundIDs.isNotEmpty) { if (response.notFoundIDs.isNotEmpty) {
debugPrint( debugPrint('[IAPService] Products not found: ${response.notFoundIDs}');
'[IAPService] Products not found: ${response.notFoundIDs}',
);
} }
for (final product in response.productDetails) { for (final product in response.productDetails) {
@@ -238,14 +236,10 @@ class IAPService {
} }
// 구매 요청 // 구매 요청
final purchaseParam = PurchaseParam( final purchaseParam = PurchaseParam(productDetails: _removeAdsProduct!);
productDetails: _removeAdsProduct!,
);
try { try {
final success = await _iap.buyNonConsumable( final success = await _iap.buyNonConsumable(purchaseParam: purchaseParam);
purchaseParam: purchaseParam,
);
debugPrint('[IAPService] Purchase initiated: $success'); debugPrint('[IAPService] Purchase initiated: $success');
return success ? IAPResult.success : IAPResult.failed; return success ? IAPResult.success : IAPResult.failed;
} catch (e) { } catch (e) {

View File

@@ -164,8 +164,9 @@ class ItemService {
final magDef = hasMagDef ? (def * 0.7).round() : 0; final magDef = hasMagDef ? (def * 0.7).round() : 0;
// HP 보너스 (Uncommon 이상) // HP 보너스 (Uncommon 이상)
final hpBonus = final hpBonus = rarity.index >= ItemRarity.uncommon.index
rarity.index >= ItemRarity.uncommon.index ? baseValue ~/ 3 : 0; ? baseValue ~/ 3
: 0;
// CON 보너스 (Rare 이상) // CON 보너스 (Rare 이상)
final conBonus = rarity.index >= ItemRarity.rare.index ? rarity.index : 0; final conBonus = rarity.index >= ItemRarity.rare.index ? rarity.index : 0;
@@ -273,8 +274,7 @@ class ItemService {
EquipmentSlot.greaves => ItemStats( EquipmentSlot.greaves => ItemStats(
def: def, def: def,
hpBonus: rarity.index >= ItemRarity.rare.index ? baseValue ~/ 3 : 0, hpBonus: rarity.index >= ItemRarity.rare.index ? baseValue ~/ 3 : 0,
conBonus: conBonus: rarity.index >= ItemRarity.rare.index ? rarity.index - 1 : 0,
rarity.index >= ItemRarity.rare.index ? rarity.index - 1 : 0,
evasion: rarity.index >= ItemRarity.epic.index ? 0.01 : 0.0, evasion: rarity.index >= ItemRarity.epic.index ? 0.01 : 0.0,
), ),

View File

@@ -9,10 +9,7 @@ import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
/// 판매 처리 결과 /// 판매 처리 결과
class SellResult { class SellResult {
const SellResult({ const SellResult({required this.state, required this.continuesSelling});
required this.state,
required this.continuesSelling,
});
final GameState state; final GameState state;
final bool continuesSelling; final bool continuesSelling;

View File

@@ -193,7 +193,11 @@ class ProgressService {
} }
// 5. 시장/판매/구매 태스크 완료 처리 // 5. 시장/판매/구매 태스크 완료 처리
final marketResult = _handleMarketTaskCompletion(nextState, progress, queue); final marketResult = _handleMarketTaskCompletion(
nextState,
progress,
queue,
);
if (marketResult.earlyReturn != null) return marketResult.earlyReturn!; if (marketResult.earlyReturn != null) return marketResult.earlyReturn!;
nextState = marketResult.state; nextState = marketResult.state;
progress = marketResult.progress; progress = marketResult.progress;
@@ -209,7 +213,11 @@ class ProgressService {
// 7. 퀘스트 진행 처리 // 7. 퀘스트 진행 처리
final questResult = _handleQuestProgress( final questResult = _handleQuestProgress(
nextState, progress, queue, gain, incrementSeconds, nextState,
progress,
queue,
gain,
incrementSeconds,
); );
nextState = questResult.state; nextState = questResult.state;
progress = questResult.progress; progress = questResult.progress;
@@ -217,9 +225,7 @@ class ProgressService {
questDone = questResult.completed; questDone = questResult.completed;
// 8. 플롯 진행 및 Act Boss 소환 처리 // 8. 플롯 진행 및 Act Boss 소환 처리
progress = _handlePlotProgress( progress = _handlePlotProgress(nextState, progress, gain, incrementSeconds);
nextState, progress, gain, incrementSeconds,
);
// 9. 다음 태스크 디큐/생성 // 9. 다음 태스크 디큐/생성
final dequeueResult = _handleTaskDequeue(nextState, progress, queue); final dequeueResult = _handleTaskDequeue(nextState, progress, queue);
@@ -341,7 +347,8 @@ class ProgressService {
ProgressState progress, ProgressState progress,
QueueState queue, QueueState queue,
ProgressTickResult? earlyReturn, ProgressTickResult? earlyReturn,
}) _handleKillTaskCompletion( })
_handleKillTaskCompletion(
GameState state, GameState state,
ProgressState progress, ProgressState progress,
QueueState queue, QueueState queue,
@@ -358,8 +365,9 @@ class ProgressService {
final klass = ClassData.findById(nextState.traits.classId); final klass = ClassData.findById(nextState.traits.classId);
if (klass != null) { if (klass != null) {
final postCombatHealRate = final postCombatHealRate = klass.getPassiveValue(
klass.getPassiveValue(ClassPassiveType.postCombatHeal); ClassPassiveType.postCombatHeal,
);
if (postCombatHealRate > 0) { if (postCombatHealRate > 0) {
healAmount += (maxHp * postCombatHealRate).round(); healAmount += (maxHp * postCombatHealRate).round();
} }
@@ -446,7 +454,8 @@ class ProgressService {
ProgressState progress, ProgressState progress,
QueueState queue, QueueState queue,
ProgressTickResult? earlyReturn, ProgressTickResult? earlyReturn,
}) _handleMarketTaskCompletion( })
_handleMarketTaskCompletion(
GameState state, GameState state,
ProgressState progress, ProgressState progress,
QueueState queue, QueueState queue,
@@ -520,12 +529,8 @@ class ProgressService {
} }
/// 퀘스트 진행 처리 /// 퀘스트 진행 처리
({ ({GameState state, ProgressState progress, QueueState queue, bool completed})
GameState state, _handleQuestProgress(
ProgressState progress,
QueueState queue,
bool completed,
}) _handleQuestProgress(
GameState state, GameState state,
ProgressState progress, ProgressState progress,
QueueState queue, QueueState queue,
@@ -603,7 +608,8 @@ class ProgressService {
QueueState queue, QueueState queue,
bool actDone, bool actDone,
bool gameComplete, bool gameComplete,
}) _handleTaskDequeue( })
_handleTaskDequeue(
GameState state, GameState state,
ProgressState progress, ProgressState progress,
QueueState queue, QueueState queue,
@@ -705,10 +711,7 @@ class ProgressService {
4 * 1000, 4 * 1000,
); );
final updatedProgress = taskResult.progress.copyWith( final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo( currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.market),
caption: taskResult.caption,
type: TaskType.market,
),
currentCombat: null, currentCombat: null,
); );
return (progress: updatedProgress, queue: queue); return (progress: updatedProgress, queue: queue);
@@ -1171,8 +1174,10 @@ class ProgressService {
final shouldLoseEquipment = roll < lossChancePercent; final shouldLoseEquipment = roll < lossChancePercent;
// ignore: avoid_print // ignore: avoid_print
print('[Death] Lv$level lossChance=$lossChancePercent% roll=$roll ' print(
'shouldLose=$shouldLoseEquipment'); '[Death] Lv$level lossChance=$lossChancePercent% roll=$roll '
'shouldLose=$shouldLoseEquipment',
);
if (shouldLoseEquipment) { if (shouldLoseEquipment) {
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제 // 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제

View File

@@ -346,7 +346,10 @@ class ResurrectionService {
// 해당 슬롯에 아이템 복원 // 해당 슬롯에 아이템 복원
final slotIndex = lostSlot.index; final slotIndex = lostSlot.index;
final updatedEquipment = state.equipment.setItemByIndex(slotIndex, lostItem); final updatedEquipment = state.equipment.setItemByIndex(
slotIndex,
lostItem,
);
// DeathInfo에서 상실 아이템 정보 제거 (복구 완료) // DeathInfo에서 상실 아이템 정보 제거 (복구 완료)
final updatedDeathInfo = deathInfo.copyWith( final updatedDeathInfo = deathInfo.copyWith(

View File

@@ -94,8 +94,10 @@ class ReturnRewardsService {
// 보너스 상자 (광고 시청 시 동일 개수 추가) // 보너스 상자 (광고 시청 시 동일 개수 추가)
final bonusChestCount = chestCount; final bonusChestCount = chestCount;
debugPrint('[ReturnRewards] $hoursAway hours away, ' debugPrint(
'chests=$chestCount, bonus=$bonusChestCount, paid=$isPaidUser'); '[ReturnRewards] $hoursAway hours away, '
'chests=$chestCount, bonus=$bonusChestCount, paid=$isPaidUser',
);
return ReturnChestReward( return ReturnChestReward(
hoursAway: hoursAway, hoursAway: hoursAway,
@@ -125,9 +127,14 @@ class ReturnRewardsService {
/// [reward] 복귀 보상 데이터 /// [reward] 복귀 보상 데이터
/// [playerLevel] 플레이어 레벨 /// [playerLevel] 플레이어 레벨
/// Returns: 오픈된 상자 보상 목록 /// Returns: 오픈된 상자 보상 목록
List<ChestReward> claimBasicReward(ReturnChestReward reward, int playerLevel) { List<ChestReward> claimBasicReward(
ReturnChestReward reward,
int playerLevel,
) {
if (!reward.hasReward) return []; if (!reward.hasReward) return [];
debugPrint('[ReturnRewards] Basic reward claimed: ${reward.chestCount} chests'); debugPrint(
'[ReturnRewards] Basic reward claimed: ${reward.chestCount} chests',
);
return openChests(reward.chestCount, playerLevel); return openChests(reward.chestCount, playerLevel);
} }
@@ -146,8 +153,10 @@ class ReturnRewardsService {
// 유료 유저는 무료 보너스 // 유료 유저는 무료 보너스
if (IAPService.instance.isAdRemovalPurchased) { if (IAPService.instance.isAdRemovalPurchased) {
debugPrint('[ReturnRewards] Bonus claimed (paid user): ' debugPrint(
'${reward.bonusChestCount} chests'); '[ReturnRewards] Bonus claimed (paid user): '
'${reward.bonusChestCount} chests',
);
return openChests(reward.bonusChestCount, playerLevel); return openChests(reward.bonusChestCount, playerLevel);
} }
@@ -161,8 +170,10 @@ class ReturnRewardsService {
); );
if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) { if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) {
debugPrint('[ReturnRewards] Bonus claimed (free user with ad): ' debugPrint(
'${bonusRewards.length} chests'); '[ReturnRewards] Bonus claimed (free user with ad): '
'${bonusRewards.length} chests',
);
return bonusRewards; return bonusRewards;
} }

View File

@@ -57,46 +57,64 @@ class CombatStats with _$CombatStats {
// 기본 스탯 // 기본 스탯
/// 힘: 물리 공격력 보정 /// 힘: 물리 공격력 보정
required int str, required int str,
/// 체력: HP, 방어력 보정 /// 체력: HP, 방어력 보정
required int con, required int con,
/// 민첩: 회피율, 크리티컬율, 명중률, 공격 속도 /// 민첩: 회피율, 크리티컬율, 명중률, 공격 속도
required int dex, required int dex,
/// 지능: 마법 공격력, MP /// 지능: 마법 공격력, MP
required int intelligence, required int intelligence,
/// 지혜: 마법 방어력, MP 회복 /// 지혜: 마법 방어력, MP 회복
required int wis, required int wis,
/// 매력: 상점 가격, 드롭률 보정 /// 매력: 상점 가격, 드롭률 보정
required int cha, required int cha,
// 파생 스탯 (전투용) // 파생 스탯 (전투용)
/// 물리 공격력 /// 물리 공격력
required int atk, required int atk,
/// 물리 방어력 /// 물리 방어력
required int def, required int def,
/// 마법 공격력 /// 마법 공격력
required int magAtk, required int magAtk,
/// 마법 방어력 /// 마법 방어력
required int magDef, required int magDef,
/// 크리티컬 확률 (0.0 ~ 1.0) /// 크리티컬 확률 (0.0 ~ 1.0)
required double criRate, required double criRate,
/// 크리티컬 데미지 배율 (1.5 ~ 3.0) /// 크리티컬 데미지 배율 (1.5 ~ 3.0)
required double criDamage, required double criDamage,
/// 회피율 (0.0 ~ 0.5) /// 회피율 (0.0 ~ 0.5)
required double evasion, required double evasion,
/// 명중률 (0.8 ~ 1.0) /// 명중률 (0.8 ~ 1.0)
required double accuracy, required double accuracy,
/// 방패 방어율 (0.0 ~ 0.4) /// 방패 방어율 (0.0 ~ 0.4)
required double blockRate, required double blockRate,
/// 무기로 쳐내기 확률 (0.0 ~ 0.3) /// 무기로 쳐내기 확률 (0.0 ~ 0.3)
required double parryRate, required double parryRate,
/// 공격 딜레이 (밀리초) /// 공격 딜레이 (밀리초)
required int attackDelayMs, required int attackDelayMs,
// 자원 // 자원
/// 최대 HP /// 최대 HP
required int hpMax, required int hpMax,
/// 현재 HP /// 현재 HP
required int hpCurrent, required int hpCurrent,
/// 최대 MP /// 최대 MP
required int mpMax, required int mpMax,
/// 현재 MP /// 현재 MP
required int mpCurrent, required int mpCurrent,
}) = _CombatStats; }) = _CombatStats;

View File

@@ -41,36 +41,52 @@ class ItemStats with _$ItemStats {
const factory ItemStats({ const factory ItemStats({
/// 물리 공격력 보정 /// 물리 공격력 보정
@Default(0) int atk, @Default(0) int atk,
/// 물리 방어력 보정 /// 물리 방어력 보정
@Default(0) int def, @Default(0) int def,
/// 마법 공격력 보정 /// 마법 공격력 보정
@Default(0) int magAtk, @Default(0) int magAtk,
/// 마법 방어력 보정 /// 마법 방어력 보정
@Default(0) int magDef, @Default(0) int magDef,
/// 크리티컬 확률 보정 (0.0 ~ 1.0) /// 크리티컬 확률 보정 (0.0 ~ 1.0)
@Default(0.0) double criRate, @Default(0.0) double criRate,
/// 회피율 보정 (0.0 ~ 1.0) /// 회피율 보정 (0.0 ~ 1.0)
@Default(0.0) double evasion, @Default(0.0) double evasion,
/// 방패 방어율 (방패 전용, 0.0 ~ 1.0) /// 방패 방어율 (방패 전용, 0.0 ~ 1.0)
@Default(0.0) double blockRate, @Default(0.0) double blockRate,
/// 무기 쳐내기 확률 (무기 전용, 0.0 ~ 1.0) /// 무기 쳐내기 확률 (무기 전용, 0.0 ~ 1.0)
@Default(0.0) double parryRate, @Default(0.0) double parryRate,
/// HP 보너스 /// HP 보너스
@Default(0) int hpBonus, @Default(0) int hpBonus,
/// MP 보너스 /// MP 보너스
@Default(0) int mpBonus, @Default(0) int mpBonus,
/// STR 보너스 /// STR 보너스
@Default(0) int strBonus, @Default(0) int strBonus,
/// CON 보너스 /// CON 보너스
@Default(0) int conBonus, @Default(0) int conBonus,
/// DEX 보너스 /// DEX 보너스
@Default(0) int dexBonus, @Default(0) int dexBonus,
/// INT 보너스 /// INT 보너스
@Default(0) int intBonus, @Default(0) int intBonus,
/// WIS 보너스 /// WIS 보너스
@Default(0) int wisBonus, @Default(0) int wisBonus,
/// CHA 보너스 /// CHA 보너스
@Default(0) int chaBonus, @Default(0) int chaBonus,
/// 무기 공격속도 (밀리초, 무기 전용) /// 무기 공격속도 (밀리초, 무기 전용)
/// ///
/// 0이면 기본값(1000ms) 사용, 값이 클수록 느린 공격. /// 0이면 기본값(1000ms) 사용, 값이 클수록 느린 공격.

View File

@@ -121,16 +121,18 @@ class MonetizationState with _$MonetizationState {
List<Map<String, dynamic>>? _statsListToJson(List<Stats>? stats) { List<Map<String, dynamic>>? _statsListToJson(List<Stats>? stats) {
if (stats == null) return null; if (stats == null) return null;
return stats return stats
.map((s) => { .map(
'str': s.str, (s) => {
'con': s.con, 'str': s.str,
'dex': s.dex, 'con': s.con,
'int': s.intelligence, 'dex': s.dex,
'wis': s.wis, 'int': s.intelligence,
'cha': s.cha, 'wis': s.wis,
'hpMax': s.hpMax, 'cha': s.cha,
'mpMax': s.mpMax, 'hpMax': s.hpMax,
}) 'mpMax': s.mpMax,
},
)
.toList(); .toList();
} }

View File

@@ -62,9 +62,7 @@ class Potion {
/// ///
/// 보유 물약 수량 관리 (쿨타임은 CombatState에서 관리) /// 보유 물약 수량 관리 (쿨타임은 CombatState에서 관리)
class PotionInventory { class PotionInventory {
const PotionInventory({ const PotionInventory({this.potions = const {}});
this.potions = const {},
});
/// 보유 물약 (물약 ID → 수량) /// 보유 물약 (물약 ID → 수량)
final Map<String, int> potions; final Map<String, int> potions;
@@ -99,11 +97,7 @@ class PotionInventory {
/// 빈 인벤토리 /// 빈 인벤토리
static const empty = PotionInventory(); static const empty = PotionInventory();
PotionInventory copyWith({ PotionInventory copyWith({Map<String, int>? potions}) {
Map<String, int>? potions, return PotionInventory(potions: potions ?? this.potions);
}) {
return PotionInventory(
potions: potions ?? this.potions,
);
} }
} }

View File

@@ -28,10 +28,7 @@ class ChestReward {
/// 장비 보상 생성 /// 장비 보상 생성
factory ChestReward.equipment(EquipmentItem item) { factory ChestReward.equipment(EquipmentItem item) {
return ChestReward._( return ChestReward._(type: ChestRewardType.equipment, equipment: item);
type: ChestRewardType.equipment,
equipment: item,
);
} }
/// 포션 보상 생성 /// 포션 보상 생성
@@ -45,18 +42,12 @@ class ChestReward {
/// 골드 보상 생성 /// 골드 보상 생성
factory ChestReward.gold(int amount) { factory ChestReward.gold(int amount) {
return ChestReward._( return ChestReward._(type: ChestRewardType.gold, gold: amount);
type: ChestRewardType.gold,
gold: amount,
);
} }
/// 경험치 보상 생성 /// 경험치 보상 생성
factory ChestReward.experience(int amount) { factory ChestReward.experience(int amount) {
return ChestReward._( return ChestReward._(type: ChestRewardType.experience, experience: amount);
type: ChestRewardType.experience,
experience: amount,
);
} }
/// 보상 타입 /// 보상 타입

View File

@@ -714,7 +714,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
} }
/// 데스크톱 앱바 /// 데스크톱 앱바
PreferredSizeWidget _buildDesktopAppBar(BuildContext context, GameState state) { PreferredSizeWidget _buildDesktopAppBar(
BuildContext context,
GameState state,
) {
return AppBar( return AppBar(
backgroundColor: RetroColors.darkBrown, backgroundColor: RetroColors.darkBrown,
title: Text( title: Text(
@@ -969,9 +972,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
// Potions (물약 인벤토리) // Potions (물약 인벤토리)
_buildSectionHeader(game_l10n.uiPotions), _buildSectionHeader(game_l10n.uiPotions),
Expanded( Expanded(
child: PotionInventoryPanel( child: PotionInventoryPanel(inventory: state.potionInventory),
inventory: state.potionInventory,
),
), ),
// Encumbrance 바 // Encumbrance 바

View File

@@ -35,8 +35,8 @@ class GameSessionController extends ChangeNotifier {
DateTime Function()? now, DateTime Function()? now,
StatisticsStorage? statisticsStorage, StatisticsStorage? statisticsStorage,
HallOfFameStorage? hallOfFameStorage, HallOfFameStorage? hallOfFameStorage,
}) : _tickInterval = tickInterval, }) : _tickInterval = tickInterval,
_now = now ?? DateTime.now { _now = now ?? DateTime.now {
// 매니저 초기화 // 매니저 초기화
_statisticsManager = GameStatisticsManager( _statisticsManager = GameStatisticsManager(
statisticsStorage: statisticsStorage, statisticsStorage: statisticsStorage,
@@ -136,9 +136,9 @@ class GameSessionController extends ChangeNotifier {
int get speedBoostDuration => _speedBoostManager.speedBoostDuration; int get speedBoostDuration => _speedBoostManager.speedBoostDuration;
int get speedBoostRemainingSeconds => _speedBoostManager.getRemainingSeconds( int get speedBoostRemainingSeconds => _speedBoostManager.getRemainingSeconds(
_monetization, _monetization,
_state?.skillSystem.elapsedMs ?? 0, _state?.skillSystem.elapsedMs ?? 0,
); );
int get currentSpeedMultiplier => int get currentSpeedMultiplier =>
_speedBoostManager.getCurrentSpeedMultiplier(_loop); _speedBoostManager.getCurrentSpeedMultiplier(_loop);
@@ -472,12 +472,12 @@ class GameSessionController extends ChangeNotifier {
/// 속도 부스트 활성화 (광고 시청 후) /// 속도 부스트 활성화 (광고 시청 후)
Future<bool> activateSpeedBoost() async { Future<bool> activateSpeedBoost() async {
final (success, updatedMonetization) = final (success, updatedMonetization) = await _speedBoostManager
await _speedBoostManager.activateSpeedBoost( .activateSpeedBoost(
loop: _loop, loop: _loop,
monetization: _monetization, monetization: _monetization,
currentElapsedMs: _state?.skillSystem.elapsedMs ?? 0, currentElapsedMs: _state?.skillSystem.elapsedMs ?? 0,
); );
if (success) { if (success) {
_monetization = updatedMonetization; _monetization = updatedMonetization;

View File

@@ -308,15 +308,14 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
// 핸들 바 // 핸들 바
Padding( Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4), padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Container( child: Container(width: 60, height: 4, color: border),
width: 60,
height: 4,
color: border,
),
), ),
// 헤더 // 헤더
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
color: RetroColors.panelBgOf(context), color: RetroColors.panelBgOf(context),
@@ -515,7 +514,8 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
], ],
// === 디버그 도구 섹션 === // === 디버그 도구 섹션 ===
if (kDebugMode && widget.onCreateTestCharacter != null) ...[ if (kDebugMode &&
widget.onCreateTestCharacter != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
RetroMenuSection( RetroMenuSection(
title: L10n.of(context).debugToolsTitle, title: L10n.of(context).debugToolsTitle,
@@ -526,7 +526,9 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
icon: Icons.science, icon: Icons.science,
iconColor: RetroColors.warningOf(context), iconColor: RetroColors.warningOf(context),
label: L10n.of(context).debugCreateTestCharacter, label: L10n.of(context).debugCreateTestCharacter,
subtitle: L10n.of(context).debugCreateTestCharacterDesc, subtitle: L10n.of(
context,
).debugCreateTestCharacterDesc,
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
_showTestCharacterDialog(context); _showTestCharacterDialog(context);

View File

@@ -9,9 +9,8 @@ import 'package:asciineverdie/src/core/storage/statistics_storage.dart';
/// 세션 통계와 누적 통계를 관리하고, 게임 상태 변화에 따라 /// 세션 통계와 누적 통계를 관리하고, 게임 상태 변화에 따라
/// 통계를 자동 업데이트합니다. /// 통계를 자동 업데이트합니다.
class GameStatisticsManager { class GameStatisticsManager {
GameStatisticsManager({ GameStatisticsManager({StatisticsStorage? statisticsStorage})
StatisticsStorage? statisticsStorage, : _statisticsStorage = statisticsStorage ?? StatisticsStorage();
}) : _statisticsStorage = statisticsStorage ?? StatisticsStorage();
final StatisticsStorage _statisticsStorage; final StatisticsStorage _statisticsStorage;

View File

@@ -11,9 +11,8 @@ import 'package:flutter/foundation.dart';
/// ///
/// 게임 클리어 시 캐릭터 등록, 테스트 캐릭터 생성 등을 담당합니다. /// 게임 클리어 시 캐릭터 등록, 테스트 캐릭터 생성 등을 담당합니다.
class HallOfFameManager { class HallOfFameManager {
HallOfFameManager({ HallOfFameManager({HallOfFameStorage? hallOfFameStorage})
HallOfFameStorage? hallOfFameStorage, : _hallOfFameStorage = hallOfFameStorage ?? HallOfFameStorage();
}) : _hallOfFameStorage = hallOfFameStorage ?? HallOfFameStorage();
final HallOfFameStorage _hallOfFameStorage; final HallOfFameStorage _hallOfFameStorage;

View File

@@ -107,7 +107,8 @@ class ResurrectionManager {
updatedMonetization = monetization.copyWith(autoReviveEndMs: buffEndMs); updatedMonetization = monetization.copyWith(autoReviveEndMs: buffEndMs);
debugPrint( debugPrint(
'[Resurrection] Ad revive complete, auto-revive buff until $buffEndMs ms'); '[Resurrection] Ad revive complete, auto-revive buff until $buffEndMs ms',
);
} }
// 유료 유저는 광고 없이 부활 // 유료 유저는 광고 없이 부활

View File

@@ -49,8 +49,10 @@ class ReturnRewardsManager {
if (reward.hasReward) { if (reward.hasReward) {
_pendingReturnReward = reward; _pendingReturnReward = reward;
debugPrint('[ReturnRewards] Reward available: ${reward.chestCount} chests, ' debugPrint(
'${reward.hoursAway} hours away'); '[ReturnRewards] Reward available: ${reward.chestCount} chests, '
'${reward.hoursAway} hours away',
);
// UI에서 다이얼로그 표시를 위해 콜백 호출 // UI에서 다이얼로그 표시를 위해 콜백 호출
// startNew 후에 호출하도록 딜레이 // startNew 후에 호출하도록 딜레이
@@ -91,11 +93,13 @@ class ReturnRewardsManager {
loop?.replaceState(updatedState); // ProgressLoop 상태도 업데이트 loop?.replaceState(updatedState); // ProgressLoop 상태도 업데이트
// 저장 // 저장
unawaited(saveManager.saveState( unawaited(
updatedState, saveManager.saveState(
cheatsEnabled: cheatsEnabled, updatedState,
monetization: monetization, cheatsEnabled: cheatsEnabled,
)); monetization: monetization,
),
);
_pendingReturnReward = null; _pendingReturnReward = null;
@@ -129,18 +133,19 @@ class ReturnRewardsManager {
reward.equipment!.itemWeight > currentItem.itemWeight) { reward.equipment!.itemWeight > currentItem.itemWeight) {
debugPrint('[ReturnRewards] Equipped: ${reward.equipment!.name}'); debugPrint('[ReturnRewards] Equipped: ${reward.equipment!.name}');
return state.copyWith( return state.copyWith(
equipment: state.equipment.setItemByIndex( equipment: state.equipment.setItemByIndex(slotIndex, reward.equipment!),
slotIndex,
reward.equipment!,
),
); );
} }
// 더 좋지 않으면 판매 (골드로 변환) // 더 좋지 않으면 판매 (골드로 변환)
final sellPrice = final sellPrice = (reward.equipment!.level * 50 * 0.3).round().clamp(
(reward.equipment!.level * 50 * 0.3).round().clamp(1, 99999); 1,
debugPrint('[ReturnRewards] Sold: ${reward.equipment!.name} ' 99999,
'for $sellPrice gold'); );
debugPrint(
'[ReturnRewards] Sold: ${reward.equipment!.name} '
'for $sellPrice gold',
);
return state.copyWith( return state.copyWith(
inventory: state.inventory.copyWith( inventory: state.inventory.copyWith(
gold: state.inventory.gold + sellPrice, gold: state.inventory.gold + sellPrice,
@@ -152,8 +157,10 @@ class ReturnRewardsManager {
GameState _applyPotionReward(GameState state, ChestReward reward) { GameState _applyPotionReward(GameState state, ChestReward reward) {
if (reward.potionId == null) return state; if (reward.potionId == null) return state;
debugPrint('[ReturnRewards] Added potion: ${reward.potionId} ' debugPrint(
'x${reward.potionCount}'); '[ReturnRewards] Added potion: ${reward.potionId} '
'x${reward.potionCount}',
);
return state.copyWith( return state.copyWith(
potionInventory: state.potionInventory.addPotion( potionInventory: state.potionInventory.addPotion(
reward.potionId!, reward.potionId!,

View File

@@ -12,8 +12,8 @@ class SpeedBoostManager {
SpeedBoostManager({ SpeedBoostManager({
required bool Function() cheatsEnabledGetter, required bool Function() cheatsEnabledGetter,
required Future<List<int>> Function() getAvailableSpeeds, required Future<List<int>> Function() getAvailableSpeeds,
}) : _cheatsEnabledGetter = cheatsEnabledGetter, }) : _cheatsEnabledGetter = cheatsEnabledGetter,
_getAvailableSpeeds = getAvailableSpeeds; _getAvailableSpeeds = getAvailableSpeeds;
final bool Function() _cheatsEnabledGetter; final bool Function() _cheatsEnabledGetter;
final Future<List<int>> Function() _getAvailableSpeeds; final Future<List<int>> Function() _getAvailableSpeeds;
@@ -52,7 +52,10 @@ class SpeedBoostManager {
int get speedBoostDuration => _speedBoostDuration; int get speedBoostDuration => _speedBoostDuration;
/// 속도 부스트 남은 시간 (초) - 게임 시간(elapsedMs) 기준 계산 /// 속도 부스트 남은 시간 (초) - 게임 시간(elapsedMs) 기준 계산
int getRemainingSeconds(MonetizationState monetization, int currentElapsedMs) { int getRemainingSeconds(
MonetizationState monetization,
int currentElapsedMs,
) {
if (!_isSpeedBoostActive) return 0; if (!_isSpeedBoostActive) return 0;
final endMs = monetization.speedBoostEndMs; final endMs = monetization.speedBoostEndMs;
if (endMs == null) return 0; if (endMs == null) return 0;
@@ -203,7 +206,10 @@ class SpeedBoostManager {
if (_isSpeedBoostActive) { if (_isSpeedBoostActive) {
// 부스트 상태: 부스트 배속만 사용, 기본 배속 저장 // 부스트 상태: 부스트 배속만 사용, 기본 배속 저장
savedSpeedMultiplier = baseSpeed; savedSpeedMultiplier = baseSpeed;
return (speeds: [speedBoostMultiplier], initialSpeed: speedBoostMultiplier); return (
speeds: [speedBoostMultiplier],
initialSpeed: speedBoostMultiplier,
);
} }
// 일반 상태: 기본 배속 사용 // 일반 상태: 기본 배속 사용
return (speeds: baseAvailableSpeeds, initialSpeed: baseSpeed); return (speeds: baseAvailableSpeeds, initialSpeed: baseSpeed);

View File

@@ -37,9 +37,7 @@ class InventoryPage extends StatelessWidget {
_buildSectionHeader(context, l10n.uiPotions), _buildSectionHeader(context, l10n.uiPotions),
Expanded( Expanded(
flex: 2, flex: 2,
child: PotionInventoryPanel( child: PotionInventoryPanel(inventory: potionInventory),
inventory: potionInventory,
),
), ),
// 무게 (Encumbrance) // 무게 (Encumbrance)

View File

@@ -147,7 +147,11 @@ class _SkillTile extends StatelessWidget {
if (isOnCooldown) if (isOnCooldown)
const Padding( const Padding(
padding: EdgeInsets.only(right: 8), padding: EdgeInsets.only(right: 8),
child: Icon(Icons.hourglass_empty, size: 14, color: Colors.orange), child: Icon(
Icons.hourglass_empty,
size: 14,
color: Colors.orange,
),
), ),
_RankBadge(rank: rank), _RankBadge(rank: rank),
], ],
@@ -273,10 +277,12 @@ class _SkillStatsGrid extends StatelessWidget {
// 공통: MP, 쿨타임 // 공통: MP, 쿨타임
entries.add(_StatEntry(l10n.skillMpCost, '${skill.mpCost}')); entries.add(_StatEntry(l10n.skillMpCost, '${skill.mpCost}'));
entries.add(_StatEntry( entries.add(
l10n.skillCooldown, _StatEntry(
'${(skill.cooldownMs / 1000).toStringAsFixed(1)}${l10n.skillSeconds}', l10n.skillCooldown,
)); '${(skill.cooldownMs / 1000).toStringAsFixed(1)}${l10n.skillSeconds}',
),
);
// 타입별 스탯 추가 // 타입별 스탯 추가
switch (skill.type) { switch (skill.type) {
@@ -309,33 +315,40 @@ class _SkillStatsGrid extends StatelessWidget {
// DOT 정보 // DOT 정보
if (skill.isDot && skill.baseDotDamage != null) { if (skill.isDot && skill.baseDotDamage != null) {
final dotDps = skill.baseDotDamage! * final dotDps =
skill.baseDotDamage! *
(skill.baseDotDurationMs! / skill.baseDotTickMs!); (skill.baseDotDurationMs! / skill.baseDotTickMs!);
entries.add(_StatEntry(l10n.skillDot, '${dotDps.round()}')); entries.add(_StatEntry(l10n.skillDot, '${dotDps.round()}'));
} }
// HP 흡수 // HP 흡수
if (skill.lifestealPercent > 0) { if (skill.lifestealPercent > 0) {
entries.add(_StatEntry( entries.add(
l10n.skillLifesteal, _StatEntry(
'${(skill.lifestealPercent * 100).round()}%', l10n.skillLifesteal,
)); '${(skill.lifestealPercent * 100).round()}%',
),
);
} }
// 방어 무시 // 방어 무시
if (skill.targetDefReduction > 0) { if (skill.targetDefReduction > 0) {
entries.add(_StatEntry( entries.add(
l10n.skillDefPen, _StatEntry(
'${(skill.targetDefReduction * 100).round()}%', l10n.skillDefPen,
)); '${(skill.targetDefReduction * 100).round()}%',
),
);
} }
// 자해 데미지 // 자해 데미지
if (skill.selfDamagePercent > 0) { if (skill.selfDamagePercent > 0) {
entries.add(_StatEntry( entries.add(
l10n.skillSelfDmg, _StatEntry(
'${(skill.selfDamagePercent * 100).round()}%', l10n.skillSelfDmg,
)); '${(skill.selfDamagePercent * 100).round()}%',
),
);
} }
} }
@@ -347,10 +360,12 @@ class _SkillStatsGrid extends StatelessWidget {
// % 회복 // % 회복
if (skill.healPercent > 0) { if (skill.healPercent > 0) {
entries.add(_StatEntry( entries.add(
l10n.skillHealPercent, _StatEntry(
'${(skill.healPercent * 100).round()}%', l10n.skillHealPercent,
)); '${(skill.healPercent * 100).round()}%',
),
);
} }
// MP 회복 // MP 회복
@@ -361,10 +376,12 @@ class _SkillStatsGrid extends StatelessWidget {
// 부가 버프 // 부가 버프
if (skill.buff != null) { if (skill.buff != null) {
final buff = skill.buff!; final buff = skill.buff!;
entries.add(_StatEntry( entries.add(
l10n.skillBuffDuration, _StatEntry(
'${(buff.durationMs / 1000).round()}${l10n.skillSeconds}', l10n.skillBuffDuration,
)); '${(buff.durationMs / 1000).round()}${l10n.skillSeconds}',
),
);
} }
} }
@@ -373,39 +390,49 @@ class _SkillStatsGrid extends StatelessWidget {
final buff = skill.buff!; final buff = skill.buff!;
// 지속시간 // 지속시간
entries.add(_StatEntry( entries.add(
l10n.skillBuffDuration, _StatEntry(
'${(buff.durationMs / 1000).round()}${l10n.skillSeconds}', l10n.skillBuffDuration,
)); '${(buff.durationMs / 1000).round()}${l10n.skillSeconds}',
),
);
// 각 보정치 // 각 보정치
if (buff.atkModifier != 0) { if (buff.atkModifier != 0) {
final sign = buff.atkModifier > 0 ? '+' : ''; final sign = buff.atkModifier > 0 ? '+' : '';
entries.add(_StatEntry( entries.add(
l10n.skillAtkMod, _StatEntry(
'$sign${(buff.atkModifier * 100).round()}%', l10n.skillAtkMod,
)); '$sign${(buff.atkModifier * 100).round()}%',
),
);
} }
if (buff.defModifier != 0) { if (buff.defModifier != 0) {
final sign = buff.defModifier > 0 ? '+' : ''; final sign = buff.defModifier > 0 ? '+' : '';
entries.add(_StatEntry( entries.add(
l10n.skillDefMod, _StatEntry(
'$sign${(buff.defModifier * 100).round()}%', l10n.skillDefMod,
)); '$sign${(buff.defModifier * 100).round()}%',
),
);
} }
if (buff.criRateModifier != 0) { if (buff.criRateModifier != 0) {
final sign = buff.criRateModifier > 0 ? '+' : ''; final sign = buff.criRateModifier > 0 ? '+' : '';
entries.add(_StatEntry( entries.add(
l10n.skillCriMod, _StatEntry(
'$sign${(buff.criRateModifier * 100).round()}%', l10n.skillCriMod,
)); '$sign${(buff.criRateModifier * 100).round()}%',
),
);
} }
if (buff.evasionModifier != 0) { if (buff.evasionModifier != 0) {
final sign = buff.evasionModifier > 0 ? '+' : ''; final sign = buff.evasionModifier > 0 ? '+' : '';
entries.add(_StatEntry( entries.add(
l10n.skillEvaMod, _StatEntry(
'$sign${(buff.evasionModifier * 100).round()}%', l10n.skillEvaMod,
)); '$sign${(buff.evasionModifier * 100).round()}%',
),
);
} }
} }
@@ -414,23 +441,23 @@ class _SkillStatsGrid extends StatelessWidget {
final buff = skill.buff!; final buff = skill.buff!;
// 지속시간 // 지속시간
entries.add(_StatEntry( entries.add(
l10n.skillBuffDuration, _StatEntry(
'${(buff.durationMs / 1000).round()}${l10n.skillSeconds}', l10n.skillBuffDuration,
)); '${(buff.durationMs / 1000).round()}${l10n.skillSeconds}',
),
);
// 디버프 효과 (보통 음수) // 디버프 효과 (보통 음수)
if (buff.atkModifier != 0) { if (buff.atkModifier != 0) {
entries.add(_StatEntry( entries.add(
l10n.skillAtkMod, _StatEntry(l10n.skillAtkMod, '${(buff.atkModifier * 100).round()}%'),
'${(buff.atkModifier * 100).round()}%', );
));
} }
if (buff.defModifier != 0) { if (buff.defModifier != 0) {
entries.add(_StatEntry( entries.add(
l10n.skillDefMod, _StatEntry(l10n.skillDefMod, '${(buff.defModifier * 100).round()}%'),
'${(buff.defModifier * 100).round()}%', );
));
} }
} }
} }

View File

@@ -333,7 +333,8 @@ class DeathOverlay extends StatelessWidget {
TextSpan( TextSpan(
children: [ children: [
TextSpan( TextSpan(
text: '[${_getSlotName(deathInfo.lostItemSlot)}] ', text:
'[${_getSlotName(deathInfo.lostItemSlot)}] ',
style: TextStyle(color: muted), style: TextStyle(color: muted),
), ),
TextSpan( TextSpan(
@@ -485,7 +486,10 @@ class DeathOverlay extends StatelessWidget {
border: Border( border: Border(
top: BorderSide(color: gold, width: 3), top: BorderSide(color: gold, width: 3),
left: BorderSide(color: gold, width: 3), left: BorderSide(color: gold, width: 3),
bottom: BorderSide(color: goldDark.withValues(alpha: 0.8), width: 3), bottom: BorderSide(
color: goldDark.withValues(alpha: 0.8),
width: 3,
),
right: BorderSide(color: goldDark.withValues(alpha: 0.8), width: 3), right: BorderSide(color: goldDark.withValues(alpha: 0.8), width: 3),
), ),
), ),
@@ -495,10 +499,7 @@ class DeathOverlay extends StatelessWidget {
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text('', style: TextStyle(fontSize: 20, color: gold)),
'',
style: TextStyle(fontSize: 20, color: gold),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
l10n.deathAdRevive.toUpperCase(), l10n.deathAdRevive.toUpperCase(),
@@ -551,7 +552,8 @@ class DeathOverlay extends StatelessWidget {
_buildBenefitRow( _buildBenefitRow(
context, context,
icon: '🔄', icon: '🔄',
text: '${l10n.deathAdReviveItem}: ${deathInfo.lostItemName}', text:
'${l10n.deathAdReviveItem}: ${deathInfo.lostItemName}',
color: itemRarityColor, color: itemRarityColor,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),

View File

@@ -83,7 +83,9 @@ class RetroOptionItem extends StatelessWidget {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? gold.withValues(alpha: 0.15) : Colors.transparent, color: isSelected
? gold.withValues(alpha: 0.15)
: Colors.transparent,
border: Border.all( border: Border.all(
color: isSelected ? gold : border, color: isSelected ? gold : border,
width: isSelected ? 2 : 1, width: isSelected ? 2 : 1,
@@ -101,7 +103,9 @@ class RetroOptionItem extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 18, fontSize: 18,
color: isSelected ? gold : RetroColors.textPrimaryOf(context), color: isSelected
? gold
: RetroColors.textPrimaryOf(context),
), ),
), ),
), ),

View File

@@ -300,7 +300,9 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
child: _buildBuffChip( child: _buildBuffChip(
icon: '', icon: '',
label: '${widget.adSpeedMultiplier}x', label: '${widget.adSpeedMultiplier}x',
remainingMs: widget.isPaidUser ? -1 : _speedBoostRemainingMs, remainingMs: widget.isPaidUser
? -1
: _speedBoostRemainingMs,
color: Colors.orange, color: Colors.orange,
isPermanent: widget.isPaidUser, isPermanent: widget.isPaidUser,
), ),
@@ -401,7 +403,9 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
child: SizedBox.expand( child: SizedBox.expand(
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0), value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.red.withValues(alpha: 0.2), backgroundColor: Colors.red.withValues(
alpha: 0.2,
),
valueColor: AlwaysStoppedAnimation( valueColor: AlwaysStoppedAnimation(
isLow ? Colors.red : Colors.red.shade600, isLow ? Colors.red : Colors.red.shade600,
), ),
@@ -502,7 +506,9 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
child: SizedBox.expand( child: SizedBox.expand(
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0), value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.blue.withValues(alpha: 0.2), backgroundColor: Colors.blue.withValues(
alpha: 0.2,
),
valueColor: AlwaysStoppedAnimation( valueColor: AlwaysStoppedAnimation(
Colors.blue.shade600, Colors.blue.shade600,
), ),
@@ -619,7 +625,10 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
color: Colors.black.withValues(alpha: 0.8), color: Colors.black.withValues(alpha: 0.8),
blurRadius: 2, blurRadius: 2,
), ),
const Shadow(color: Colors.black, blurRadius: 4), const Shadow(
color: Colors.black,
blurRadius: 4,
),
], ],
), ),
), ),
@@ -783,8 +792,9 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
), ),
TextSpan( TextSpan(
text: _getStatusMessage(), text: _getStatusMessage(),
style: style: gradeColor != null
gradeColor != null ? TextStyle(color: gradeColor) : null, ? TextStyle(color: gradeColor)
: null,
), ),
], ],
), ),
@@ -844,10 +854,7 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(icon, style: TextStyle(fontSize: 12, color: color)),
icon,
style: TextStyle(fontSize: 12, color: color),
),
if (label != null) ...[ if (label != null) ...[
const SizedBox(width: 2), const SizedBox(width: 2),
Text( Text(

View File

@@ -326,8 +326,10 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
height: 14, height: 14,
decoration: BoxDecoration( decoration: BoxDecoration(
color: emptyColor.withValues(alpha: 0.3), color: emptyColor.withValues(alpha: 0.3),
border: border: Border.all(
Border.all(color: RetroColors.panelBorderOuter, width: 1), color: RetroColors.panelBorderOuter,
width: 1,
),
), ),
child: Row( child: Row(
children: List.generate(segmentCount, (index) { children: List.generate(segmentCount, (index) {
@@ -341,10 +343,8 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
border: Border( border: Border(
right: index < segmentCount - 1 right: index < segmentCount - 1
? BorderSide( ? BorderSide(
color: color: RetroColors.panelBorderOuter
RetroColors.panelBorderOuter.withValues( .withValues(alpha: 0.3),
alpha: 0.3,
),
width: 1, width: 1,
) )
: BorderSide.none, : BorderSide.none,

View File

@@ -4,11 +4,7 @@ import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 메뉴 섹션 타이틀 /// 메뉴 섹션 타이틀
class RetroMenuSection extends StatelessWidget { class RetroMenuSection extends StatelessWidget {
const RetroMenuSection({ const RetroMenuSection({super.key, required this.title, this.color});
super.key,
required this.title,
this.color,
});
final String title; final String title;
final Color? color; final Color? color;
@@ -182,10 +178,7 @@ class RetroSpeedChip extends StatelessWidget {
if (isAdBased && !isSelected && !isDisabled) if (isAdBased && !isSelected && !isDisabled)
Padding( Padding(
padding: const EdgeInsets.only(right: 2), padding: const EdgeInsets.only(right: 2),
child: Text( child: Text('', style: TextStyle(fontSize: 7, color: warning)),
'',
style: TextStyle(fontSize: 7, color: warning),
),
), ),
Text( Text(
'${speed}x', '${speed}x',

View File

@@ -9,10 +9,7 @@ import 'package:asciineverdie/src/core/model/potion.dart';
/// 보유 중인 물약 목록과 수량을 표시. /// 보유 중인 물약 목록과 수량을 표시.
/// HP 물약은 빨간색, MP 물약은 파란색으로 구분. /// HP 물약은 빨간색, MP 물약은 파란색으로 구분.
class PotionInventoryPanel extends StatelessWidget { class PotionInventoryPanel extends StatelessWidget {
const PotionInventoryPanel({ const PotionInventoryPanel({super.key, required this.inventory});
super.key,
required this.inventory,
});
final PotionInventory inventory; final PotionInventory inventory;
@@ -38,10 +35,7 @@ class PotionInventoryPanel extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = potionEntries[index]; final entry = potionEntries[index];
return _PotionRow( return _PotionRow(potion: entry.potion, quantity: entry.quantity);
potion: entry.potion,
quantity: entry.quantity,
);
}, },
); );
} }
@@ -82,10 +76,7 @@ class _PotionEntry {
/// 물약 행 위젯 /// 물약 행 위젯
class _PotionRow extends StatelessWidget { class _PotionRow extends StatelessWidget {
const _PotionRow({ const _PotionRow({required this.potion, required this.quantity});
required this.potion,
required this.quantity,
});
final Potion potion; final Potion potion;
final int quantity; final int quantity;

View File

@@ -264,7 +264,8 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog>
return Transform.translate( return Transform.translate(
offset: isOpening offset: isOpening
? Offset( ? Offset(
_shakeAnimation.value * 2 * _shakeAnimation.value *
2 *
((_animController.value * 10).round() % 2 == 0 ((_animController.value * 10).round() % 2 == 0
? 1 ? 1
: -1), : -1),
@@ -314,8 +315,8 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog>
isGold isGold
? l10n.returnRewardOpenChests ? l10n.returnRewardOpenChests
: (isPaidUser : (isPaidUser
? l10n.returnRewardClaimBonusFree ? l10n.returnRewardClaimBonusFree
: l10n.returnRewardClaimBonus), : l10n.returnRewardClaimBonus),
style: TextStyle( style: TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 10, fontSize: 10,
@@ -365,10 +366,7 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog>
count, count,
(index) => Text( (index) => Text(
'📦', '📦',
style: TextStyle( style: TextStyle(fontSize: 24, color: enabled ? null : muted),
fontSize: 24,
color: enabled ? null : muted,
),
), ),
), ),
); );
@@ -387,7 +385,9 @@ class _ReturnRewardsDialogState extends State<ReturnRewardsDialog>
} }
return Column( return Column(
children: rewards.map((reward) => _buildRewardItem(context, reward)).toList(), children: rewards
.map((reward) => _buildRewardItem(context, reward))
.toList(),
); );
} }

View File

@@ -65,10 +65,7 @@ class SpeedBoostButton extends StatelessWidget {
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text('', style: TextStyle(fontSize: 18, color: expColor)),
'',
style: TextStyle(fontSize: 18, color: expColor),
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${boostMultiplier}x', '${boostMultiplier}x',
@@ -113,10 +110,7 @@ class SpeedBoostButton extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text('', style: TextStyle(fontSize: 18, color: gold)),
'',
style: TextStyle(fontSize: 18, color: gold),
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${boostMultiplier}x', '${boostMultiplier}x',
@@ -130,10 +124,7 @@ class SpeedBoostButton extends StatelessWidget {
if (!isPaidUser) ...[ if (!isPaidUser) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2), color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),

View File

@@ -257,7 +257,11 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (!isPaidUser) ...[ if (!isPaidUser) ...[
const Icon(Icons.play_circle, size: 14, color: RetroColors.gold), const Icon(
Icons.play_circle,
size: 14,
color: RetroColors.gold,
),
const SizedBox(width: 4), const SizedBox(width: 4),
], ],
Text( Text(

View File

@@ -105,8 +105,9 @@ class _ClassInfo extends StatelessWidget {
final percent = (passive.value * 100).round(); final percent = (passive.value * 100).round();
return switch (passive.type) { return switch (passive.type) {
ClassPassiveType.hpBonus => game_l10n.passiveHpBonus(percent), ClassPassiveType.hpBonus => game_l10n.passiveHpBonus(percent),
ClassPassiveType.physicalDamageBonus => ClassPassiveType.physicalDamageBonus => game_l10n.passivePhysicalBonus(
game_l10n.passivePhysicalBonus(percent), percent,
),
ClassPassiveType.defenseBonus => game_l10n.passiveDefenseBonus(percent), ClassPassiveType.defenseBonus => game_l10n.passiveDefenseBonus(percent),
ClassPassiveType.magicDamageBonus => game_l10n.passiveMagicBonus(percent), ClassPassiveType.magicDamageBonus => game_l10n.passiveMagicBonus(percent),
ClassPassiveType.evasionBonus => game_l10n.passiveEvasionBonus(percent), ClassPassiveType.evasionBonus => game_l10n.passiveEvasionBonus(percent),

View File

@@ -62,9 +62,15 @@ class StatsSection extends StatelessWidget {
// 스탯 그리드 // 스탯 그리드
Row( Row(
children: [ children: [
Expanded(child: _StatTile(label: l10n.statStr, value: str)), Expanded(
Expanded(child: _StatTile(label: l10n.statCon, value: con)), child: _StatTile(label: l10n.statStr, value: str),
Expanded(child: _StatTile(label: l10n.statDex, value: dex)), ),
Expanded(
child: _StatTile(label: l10n.statCon, value: con),
),
Expanded(
child: _StatTile(label: l10n.statDex, value: dex),
),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -73,8 +79,12 @@ class StatsSection extends StatelessWidget {
Expanded( Expanded(
child: _StatTile(label: l10n.statInt, value: intelligence), child: _StatTile(label: l10n.statInt, value: intelligence),
), ),
Expanded(child: _StatTile(label: l10n.statWis, value: wis)), Expanded(
Expanded(child: _StatTile(label: l10n.statCha, value: cha)), child: _StatTile(label: l10n.statWis, value: wis),
),
Expanded(
child: _StatTile(label: l10n.statCha, value: cha),
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -116,10 +126,7 @@ class StatsSection extends StatelessWidget {
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
_UndoButton( _UndoButton(canUndo: canUndo, onPressed: onUndo),
canUndo: canUndo,
onPressed: onUndo,
),
const SizedBox(width: 16), const SizedBox(width: 16),
_RollButton( _RollButton(
canRoll: canRoll, canRoll: canRoll,
@@ -222,11 +229,7 @@ class _UndoButton extends StatelessWidget {
children: [ children: [
// 무료 유저는 광고 아이콘 표시 // 무료 유저는 광고 아이콘 표시
if (!isPaidUser && canUndo) ...[ if (!isPaidUser && canUndo) ...[
const Icon( const Icon(Icons.play_circle, size: 14, color: RetroColors.gold),
Icons.play_circle,
size: 14,
color: RetroColors.gold,
),
const SizedBox(width: 4), const SizedBox(width: 4),
], ],
Icon( Icon(
@@ -240,7 +243,9 @@ class _UndoButton extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 11, fontSize: 11,
color: canUndo ? RetroColors.textLight : RetroColors.textDisabled, color: canUndo
? RetroColors.textLight
: RetroColors.textDisabled,
), ),
), ),
], ],

View File

@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "asciineverdie") set(BINARY_NAME "asciineverdie")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.asciineverdie") set(APPLICATION_ID "com.naturebridgeai.asciineverdie")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.

View File

@@ -479,7 +479,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.asciineverdie.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)/asciineverdie.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/asciineverdie"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/asciineverdie.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/asciineverdie";
@@ -494,7 +494,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.asciineverdie.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)/asciineverdie.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/asciineverdie"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/asciineverdie.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/asciineverdie";
@@ -509,7 +509,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.asciineverdie.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)/asciineverdie.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/asciineverdie"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/asciineverdie.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/asciineverdie";

View File

@@ -8,7 +8,7 @@
PRODUCT_NAME = asciineverdie PRODUCT_NAME = asciineverdie
// The application's bundle identifier // The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.example.asciineverdie PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.asciineverdie
// The copyright displayed in application information // The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved.

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1 version: 1.0.1+2
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2

View File

@@ -9,7 +9,7 @@ void main() {
group('playerAttackMonster', () { group('playerAttackMonster', () {
test('데미지 = (ATK * variation) - (DEF * 0.4)', () { test('데미지 = (ATK * variation) - (DEF * 0.4)', () {
// 고정 시드로 예측 가능한 결과 // 고정 시드로 예측 가능한 결과
final rng = DeterministicRandom( 42); final rng = DeterministicRandom(42);
final calculator = CombatCalculator(rng: rng); final calculator = CombatCalculator(rng: rng);
final player = CombatStats.empty().copyWith( final player = CombatStats.empty().copyWith(
@@ -49,7 +49,7 @@ void main() {
test('크리티컬 발동 시 데미지 배율 적용', () { test('크리티컬 발동 시 데미지 배율 적용', () {
// 크리티컬이 항상 발동하도록 criRate = 1.0 // 크리티컬이 항상 발동하도록 criRate = 1.0
final rng = DeterministicRandom( 123); final rng = DeterministicRandom(123);
final calculator = CombatCalculator(rng: rng); final calculator = CombatCalculator(rng: rng);
final player = CombatStats.empty().copyWith( final player = CombatStats.empty().copyWith(
@@ -87,7 +87,7 @@ void main() {
}); });
test('회피 발동 시 0 데미지', () { test('회피 발동 시 0 데미지', () {
final rng = DeterministicRandom( 42); final rng = DeterministicRandom(42);
final calculator = CombatCalculator(rng: rng); final calculator = CombatCalculator(rng: rng);
final player = CombatStats.empty().copyWith( final player = CombatStats.empty().copyWith(
@@ -130,7 +130,7 @@ void main() {
test('블록 발동 시 70% 감소', () { test('블록 발동 시 70% 감소', () {
// 블록이 발동하는 시드 찾기 // 블록이 발동하는 시드 찾기
// blockRate = 1.0으로 항상 블록 // blockRate = 1.0으로 항상 블록
final rng = DeterministicRandom( 99); final rng = DeterministicRandom(99);
final calculator = CombatCalculator(rng: rng); final calculator = CombatCalculator(rng: rng);
final monster = MonsterCombatStats( final monster = MonsterCombatStats(
@@ -170,7 +170,7 @@ void main() {
}); });
test('패리 발동 시 50% 감소', () { test('패리 발동 시 50% 감소', () {
final rng = DeterministicRandom( 77); final rng = DeterministicRandom(77);
final calculator = CombatCalculator(rng: rng); final calculator = CombatCalculator(rng: rng);
final monster = MonsterCombatStats( final monster = MonsterCombatStats(
@@ -212,7 +212,7 @@ void main() {
group('estimateCombatDurationMs', () { group('estimateCombatDurationMs', () {
test('범위 2000~30000ms 내 반환', () { test('범위 2000~30000ms 내 반환', () {
final rng = DeterministicRandom( 42); final rng = DeterministicRandom(42);
final calculator = CombatCalculator(rng: rng); final calculator = CombatCalculator(rng: rng);
final player = CombatStats.empty().copyWith( final player = CombatStats.empty().copyWith(
@@ -247,7 +247,7 @@ void main() {
}); });
test('고레벨 몬스터는 더 긴 전투 시간', () { test('고레벨 몬스터는 더 긴 전투 시간', () {
final rng = DeterministicRandom( 42); final rng = DeterministicRandom(42);
final calculator = CombatCalculator(rng: rng); final calculator = CombatCalculator(rng: rng);
final player = CombatStats.empty().copyWith( final player = CombatStats.empty().copyWith(
@@ -305,7 +305,7 @@ void main() {
group('evaluateDifficulty', () { group('evaluateDifficulty', () {
test('범위 0.0~1.0 내 반환', () { test('범위 0.0~1.0 내 반환', () {
final rng = DeterministicRandom( 42); final rng = DeterministicRandom(42);
final calculator = CombatCalculator(rng: rng); final calculator = CombatCalculator(rng: rng);
final player = CombatStats.empty().copyWith( final player = CombatStats.empty().copyWith(

View File

@@ -125,7 +125,10 @@ void main() {
// ATK 100 * 2.0 - DEF 50 * 0.3 = 200 - 15 = 185 // ATK 100 * 2.0 - DEF 50 * 0.3 = 200 - 15 = 185
expect(result.result.success, isTrue); expect(result.result.success, isTrue);
expect(result.result.damage, equals(185)); expect(result.result.damage, equals(185));
expect(result.updatedPlayer.mpCurrent, equals(20)); // 50 - 30 (mpCost 30) expect(
result.updatedPlayer.mpCurrent,
equals(20),
); // 50 - 30 (mpCost 30)
expect(result.updatedMonster.hpCurrent, equals(315)); // 500 - 185 expect(result.updatedMonster.hpCurrent, equals(315)); // 500 - 185
}); });
@@ -349,10 +352,7 @@ void main() {
const skill = SkillData.memoryDump; const skill = SkillData.memoryDump;
// baseDotDamage: 10, baseDotDurationMs: 6000, baseDotTickMs: 1000 // baseDotDamage: 10, baseDotDurationMs: 6000, baseDotTickMs: 1000
final player = CombatStats.empty().copyWith( final player = CombatStats.empty().copyWith(mpMax: 100, mpCurrent: 50);
mpMax: 100,
mpCurrent: 50,
);
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
final result = service.useDotSkill( final result = service.useDotSkill(
@@ -379,10 +379,7 @@ void main() {
final service = SkillService(rng: rng); final service = SkillService(rng: rng);
const skill = SkillData.memoryDump; const skill = SkillData.memoryDump;
final player = CombatStats.empty().copyWith( final player = CombatStats.empty().copyWith(mpMax: 100, mpCurrent: 50);
mpMax: 100,
mpCurrent: 50,
);
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
final result = service.useDotSkill( final result = service.useDotSkill(
@@ -403,10 +400,7 @@ void main() {
final service = SkillService(rng: rng); final service = SkillService(rng: rng);
const skill = SkillData.memoryDump; const skill = SkillData.memoryDump;
final player = CombatStats.empty().copyWith( final player = CombatStats.empty().copyWith(mpMax: 100, mpCurrent: 50);
mpMax: 100,
mpCurrent: 50,
);
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
final result = service.useDotSkill( final result = service.useDotSkill(
@@ -555,10 +549,7 @@ void main() {
final service = SkillService(rng: rng); final service = SkillService(rng: rng);
const skill = SkillData.debugMode; // ATK +25% 버프, mpCost: 100 const skill = SkillData.debugMode; // ATK +25% 버프, mpCost: 100
final player = CombatStats.empty().copyWith( final player = CombatStats.empty().copyWith(mpMax: 200, mpCurrent: 150);
mpMax: 200,
mpCurrent: 150,
);
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000); final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
final result = service.useBuffSkill( final result = service.useBuffSkill(
@@ -569,10 +560,7 @@ void main() {
expect(result.result.success, isTrue); expect(result.result.success, isTrue);
expect(result.result.appliedBuff, isNotNull); expect(result.result.appliedBuff, isNotNull);
expect( expect(result.result.appliedBuff!.effect.atkModifier, equals(0.25));
result.result.appliedBuff!.effect.atkModifier,
equals(0.25),
);
expect(result.updatedSkillSystem.activeBuffs.length, equals(1)); expect(result.updatedSkillSystem.activeBuffs.length, equals(1));
expect(result.updatedPlayer.mpCurrent, equals(50)); // 150 - 100 expect(result.updatedPlayer.mpCurrent, equals(50)); // 150 - 100
}); });
@@ -582,10 +570,7 @@ void main() {
final service = SkillService(rng: rng); final service = SkillService(rng: rng);
const skill = SkillData.debugMode; const skill = SkillData.debugMode;
final player = CombatStats.empty().copyWith( final player = CombatStats.empty().copyWith(mpMax: 100, mpCurrent: 50);
mpMax: 100,
mpCurrent: 50,
);
final existingBuff = const ActiveBuff( final existingBuff = const ActiveBuff(
effect: BuffEffect( effect: BuffEffect(
id: 'debug_mode_buff', id: 'debug_mode_buff',

View File

@@ -26,10 +26,7 @@ void main() {
final race = RaceTraits( final race = RaceTraits(
raceId: 'test_race', raceId: 'test_race',
name: 'Test Race', name: 'Test Race',
statModifiers: const { statModifiers: const {StatType.str: 2, StatType.intelligence: -1},
StatType.str: 2,
StatType.intelligence: -1,
},
); );
// 보정 없는 클래스 // 보정 없는 클래스
@@ -73,10 +70,7 @@ void main() {
const klass = ClassTraits( const klass = ClassTraits(
classId: 'test_class', classId: 'test_class',
name: 'Test Class', name: 'Test Class',
statModifiers: { statModifiers: {StatType.con: 3, StatType.dex: 1},
StatType.con: 3,
StatType.dex: 1,
},
); );
final result = calculator.applyModifiers( final result = calculator.applyModifiers(
@@ -248,10 +242,7 @@ void main() {
name: 'Test Class', name: 'Test Class',
statModifiers: {}, statModifiers: {},
passives: [ passives: [
ClassPassive( ClassPassive(type: ClassPassiveType.evasionBonus, value: 0.15),
type: ClassPassiveType.evasionBonus,
value: 0.15,
),
], ],
); );
@@ -265,10 +256,7 @@ void main() {
}); });
test('물리 공격력 보너스 적용', () { test('물리 공격력 보너스 적용', () {
final combatStats = CombatStats.empty().copyWith( final combatStats = CombatStats.empty().copyWith(atk: 100, def: 20);
atk: 100,
def: 20,
);
const race = RaceTraits( const race = RaceTraits(
raceId: 'test_race', raceId: 'test_race',
@@ -322,10 +310,7 @@ void main() {
name: 'Test Class', name: 'Test Class',
statModifiers: {}, statModifiers: {},
passives: [ passives: [
ClassPassive( ClassPassive(type: ClassPassiveType.postCombatHeal, value: 0.05),
type: ClassPassiveType.postCombatHeal,
value: 0.05,
),
], ],
); );

View File

@@ -80,7 +80,8 @@ void main() {
test('loadAndStart surfaces save load errors', () { test('loadAndStart surfaces save load errors', () {
fakeAsync((async) { fakeAsync((async) {
final saveManager = FakeSaveManager() final saveManager = FakeSaveManager()
..onLoad = (_) => (const SaveOutcome.failure('boom'), null, false, null); ..onLoad = (_) =>
(const SaveOutcome.failure('boom'), null, false, null);
final controller = buildController(async, saveManager); final controller = buildController(async, saveManager);
controller.loadAndStart(fileName: 'bad.pqf'); controller.loadAndStart(fileName: 'bad.pqf');

View File

@@ -95,10 +95,7 @@ class MockFactories {
/// ///
/// [seed]: 결정론적 랜덤 시드 /// [seed]: 결정론적 랜덤 시드
/// [level]: 캐릭터 레벨 /// [level]: 캐릭터 레벨
static GameState createGameState({ static GameState createGameState({int seed = 42, int level = 1}) {
int seed = 42,
int level = 1,
}) {
return GameState.withSeed(seed: seed); return GameState.withSeed(seed: seed);
} }