Compare commits
74 Commits
7b9f1f87a6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96fd5e43d9 | ||
|
|
68a5848510 | ||
|
|
068d9da4bd | ||
|
|
0116db1056 | ||
|
|
b346fdebe8 | ||
|
|
69e7695cb7 | ||
|
|
ea54b4c501 | ||
|
|
e2a9032009 | ||
|
|
b240cd2626 | ||
|
|
017f2fdb91 | ||
|
|
eee32c94b8 | ||
|
|
b1de31fc12 | ||
|
|
4515f470c8 | ||
|
|
b0568480f6 | ||
|
|
4c502df573 | ||
|
|
6156eef90d | ||
|
|
a2496d219e | ||
|
|
9be0dd3e4f | ||
|
|
5d38bac79e | ||
|
|
9d5bb46856 | ||
|
|
45d2544437 | ||
|
|
e051bd451a | ||
|
|
c382d6d770 | ||
|
|
019879bf9e | ||
|
|
fd93ad4f90 | ||
|
|
8c10ca760b | ||
|
|
916a50992c | ||
|
|
6f5b3ba8f4 | ||
|
|
863c52600f | ||
|
|
c54681df8c | ||
|
|
0033e35665 | ||
|
|
13b698712e | ||
|
|
d9132a72ea | ||
|
|
3d5e0af84d | ||
|
|
a45eafa8fc | ||
|
|
7fcae4893e | ||
|
|
2e66562ea2 | ||
|
|
c4280c929d | ||
|
|
45b2c336cd | ||
|
|
b98451919a | ||
|
|
4791bda669 | ||
|
|
f6799e0243 | ||
|
|
fc15198c57 | ||
|
|
c56e76b176 | ||
|
|
dadd25837d | ||
|
|
e13e8032d9 | ||
|
|
864a866039 | ||
|
|
6ddbf23816 | ||
|
|
1a8858a3b1 | ||
|
|
faaa5af54e | ||
|
|
68284323c8 | ||
|
|
8f351df0b6 | ||
|
|
8fcb7bf2b7 | ||
|
|
d07a0c5554 | ||
|
|
bccb5cb188 | ||
|
|
6994f4fc9b | ||
|
|
ea64571eed | ||
|
|
1ff4208f06 | ||
|
|
067c295163 | ||
|
|
ea6ebf55f5 | ||
|
|
41f73bc14c | ||
|
|
54a2d128aa | ||
|
|
73e96bcf50 | ||
|
|
e37a2ddfa8 | ||
|
|
3be9d346dd | ||
|
|
d9a2fe358c | ||
|
|
faf87eccb0 | ||
|
|
7f44e95163 | ||
|
|
742b0d1773 | ||
|
|
97b40ccb1f | ||
|
|
75bc39528f | ||
|
|
c5eaecfa6a | ||
|
|
c577f9deed | ||
|
|
e516076ce8 |
95
.claude/agents/dev-architecture.md
Normal file
95
.claude/agents/dev-architecture.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
name: dev-architecture
|
||||||
|
description: Architecture review agent. Clean architecture compliance, SOLID principles, module boundaries, dependency direction, component coupling analysis
|
||||||
|
---
|
||||||
|
|
||||||
|
# Architecture Review Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
Evaluate the structural design and architectural health of a development project.
|
||||||
|
Answers: "Is this codebase well-structured, maintainable, and scalable?"
|
||||||
|
|
||||||
|
## Input
|
||||||
|
Receives an absolute directory path. Must scan and analyze ALL source files and project structure within.
|
||||||
|
|
||||||
|
## Analysis Framework
|
||||||
|
|
||||||
|
### 1. Project Structure Analysis
|
||||||
|
- Directory layout and organization
|
||||||
|
- Separation of concerns (presentation / domain / data layers)
|
||||||
|
- Module boundaries and encapsulation
|
||||||
|
- File naming conventions consistency
|
||||||
|
|
||||||
|
### 2. Dependency Direction
|
||||||
|
- Clean Architecture compliance: dependencies point inward only
|
||||||
|
- No domain layer depending on infrastructure/framework
|
||||||
|
- Circular dependency detection
|
||||||
|
- Import graph analysis
|
||||||
|
|
||||||
|
### 3. SOLID Principles Compliance
|
||||||
|
- **S**: Single Responsibility — files/classes with multiple concerns
|
||||||
|
- **O**: Open/Closed — extensibility without modification
|
||||||
|
- **L**: Liskov Substitution — proper interface contracts
|
||||||
|
- **I**: Interface Segregation — bloated interfaces
|
||||||
|
- **D**: Dependency Inversion — concrete vs abstract dependencies
|
||||||
|
|
||||||
|
### 4. Component Coupling & Cohesion
|
||||||
|
- Tight coupling indicators (god classes, shared mutable state)
|
||||||
|
- Cohesion assessment per module
|
||||||
|
- API surface area per module
|
||||||
|
|
||||||
|
### 5. Design Pattern Usage
|
||||||
|
- Appropriate pattern application
|
||||||
|
- Anti-patterns detected
|
||||||
|
- Over-engineering indicators
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
- `Glob`: Scan project structure
|
||||||
|
- `Grep`: Search for patterns, imports, dependencies
|
||||||
|
- `Read`: Read source files
|
||||||
|
- `Bash`: Run dependency analysis tools if available
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
Final deliverable in **Korean (한국어)**.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Project Name] Architecture Review
|
||||||
|
|
||||||
|
## Architecture Score: [1-10]
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
- Layout: [description]
|
||||||
|
- Layer separation: [GOOD/PARTIAL/NONE]
|
||||||
|
|
||||||
|
## Dependency Direction
|
||||||
|
| Violation | File | Depends On | Should Be |
|
||||||
|
|-----------|------|-----------|-----------|
|
||||||
|
|
||||||
|
## SOLID Compliance
|
||||||
|
| Principle | Score | Key Violations |
|
||||||
|
|-----------|-------|---------------|
|
||||||
|
|
||||||
|
## Coupling/Cohesion
|
||||||
|
| Module | Coupling | Cohesion | Issues |
|
||||||
|
|--------|----------|----------|--------|
|
||||||
|
|
||||||
|
## Critical Findings
|
||||||
|
1. [Finding + File:Line]
|
||||||
|
2. ...
|
||||||
|
|
||||||
|
## Recommendations (Priority Order)
|
||||||
|
1. [Critical]
|
||||||
|
2. [Important]
|
||||||
|
3. [Nice-to-have]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brutal Analysis Principles
|
||||||
|
- **No sugar-coating**: If architecture is a mess, say "ARCHITECTURE IS A MESS"
|
||||||
|
- **Evidence required**: Every finding must reference specific file:line
|
||||||
|
- **Never hide negative facts**: Spaghetti code is spaghetti code
|
||||||
|
|
||||||
|
## Claude-Gemini Cross-Debate Protocol
|
||||||
|
1. Claude runs analysis → draft
|
||||||
|
2. Gemini reviews: `gemini -y -p "{analysis + project context}" -o text`
|
||||||
|
3. Debate disagreements: `gemini -y -r latest -p "{debate}" -o text`
|
||||||
|
4. Only agreed findings in final output. Unresolved → "[NO CONSENSUS]"
|
||||||
94
.claude/agents/dev-code-quality.md
Normal file
94
.claude/agents/dev-code-quality.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
name: dev-code-quality
|
||||||
|
description: Code quality review agent. Code smells, complexity, naming, duplication, readability. Runs linters/analyzers if available
|
||||||
|
---
|
||||||
|
|
||||||
|
# Code Quality Review Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
Evaluate the code quality, readability, and maintainability of source code.
|
||||||
|
Answers: "Is this code clean, readable, and maintainable by a new developer?"
|
||||||
|
|
||||||
|
## Input
|
||||||
|
Receives an absolute directory path. Scans all source files.
|
||||||
|
|
||||||
|
## Analysis Framework
|
||||||
|
|
||||||
|
### 1. Code Smells Detection
|
||||||
|
- Long methods (>60 lines), large files (>400 lines)
|
||||||
|
- Deep nesting (>3 levels)
|
||||||
|
- Magic numbers/strings
|
||||||
|
- Dead code, commented-out code
|
||||||
|
- God objects/classes
|
||||||
|
|
||||||
|
### 2. Complexity Analysis
|
||||||
|
- Cyclomatic complexity per function
|
||||||
|
- Cognitive complexity
|
||||||
|
- Function parameter count (>3 = smell)
|
||||||
|
|
||||||
|
### 3. Naming Conventions
|
||||||
|
- Consistency check (camelCase, snake_case, PascalCase)
|
||||||
|
- Descriptive vs cryptic names
|
||||||
|
- Boolean naming (is/has/should prefixes)
|
||||||
|
- Function naming (verb-first)
|
||||||
|
|
||||||
|
### 4. Duplication
|
||||||
|
- Copy-paste code detection
|
||||||
|
- Similar logic in multiple places
|
||||||
|
- Opportunities for abstraction (only when 3+ occurrences)
|
||||||
|
|
||||||
|
### 5. Readability
|
||||||
|
- Comment quality (meaningful vs noise)
|
||||||
|
- Code self-documentation level
|
||||||
|
- Early returns vs deep nesting
|
||||||
|
|
||||||
|
### 6. Linter/Analyzer Results
|
||||||
|
- Run available linters (eslint, pylint, dart analyze, cargo clippy, etc.)
|
||||||
|
- Report warnings and errors
|
||||||
|
- Configuration quality of lint rules
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
- `Glob`, `Grep`, `Read`: Code scanning
|
||||||
|
- `Bash`: Run linters/analyzers
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
Final deliverable in **Korean (한국어)**.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Project Name] Code Quality Review
|
||||||
|
|
||||||
|
## Quality Score: [1-10]
|
||||||
|
|
||||||
|
## Code Smells
|
||||||
|
| Type | File:Line | Description | Severity |
|
||||||
|
|------|-----------|-------------|----------|
|
||||||
|
|
||||||
|
## Complexity Hotspots
|
||||||
|
| Function | File | Complexity | Recommendation |
|
||||||
|
|----------|------|-----------|---------------|
|
||||||
|
|
||||||
|
## Naming Issues
|
||||||
|
| File:Line | Current | Suggested | Rule |
|
||||||
|
|-----------|---------|-----------|------|
|
||||||
|
|
||||||
|
## Duplication
|
||||||
|
| Pattern | Locations | Lines Duplicated |
|
||||||
|
|---------|-----------|-----------------|
|
||||||
|
|
||||||
|
## Linter Results
|
||||||
|
- Tool: [name]
|
||||||
|
- Errors: [count]
|
||||||
|
- Warnings: [count]
|
||||||
|
- Key issues: ...
|
||||||
|
|
||||||
|
## Top 5 Files Needing Refactor
|
||||||
|
1. [file] — [reason]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brutal Analysis Principles
|
||||||
|
- **No sugar-coating**: Bad code is bad code. Name it
|
||||||
|
- **Evidence required**: Every finding → file:line reference
|
||||||
|
- **Never hide negative facts**: If the codebase is unmaintainable, say so
|
||||||
|
|
||||||
|
## Claude-Gemini Cross-Debate Protocol
|
||||||
|
Same protocol as all agents. Claude analyzes → Gemini reviews → debate → consensus only.
|
||||||
101
.claude/agents/dev-devops.md
Normal file
101
.claude/agents/dev-devops.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
name: dev-devops
|
||||||
|
description: DevOps review agent. CI/CD pipelines, Docker configuration, deployment setup, environment management, monitoring, logging
|
||||||
|
---
|
||||||
|
|
||||||
|
# DevOps Review Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
Evaluate the deployment, CI/CD, and operational infrastructure of the project.
|
||||||
|
Answers: "Can this be deployed reliably? Is it observable in production?"
|
||||||
|
|
||||||
|
## Input
|
||||||
|
Receives an absolute directory path. Reads CI/CD configs, Dockerfiles, deployment scripts, env files.
|
||||||
|
|
||||||
|
## Analysis Framework
|
||||||
|
|
||||||
|
### 1. CI/CD Pipeline
|
||||||
|
- Pipeline configuration present? (GitHub Actions, GitLab CI, etc.)
|
||||||
|
- Build → Test → Deploy stages
|
||||||
|
- Branch protection rules
|
||||||
|
- Automated testing in pipeline
|
||||||
|
- Deployment automation level
|
||||||
|
|
||||||
|
### 2. Containerization
|
||||||
|
- Dockerfile quality (multi-stage, layer caching, security)
|
||||||
|
- Docker Compose for local development
|
||||||
|
- Image size optimization
|
||||||
|
- Base image currency
|
||||||
|
|
||||||
|
### 3. Environment Management
|
||||||
|
- .env handling (not committed, .env.example provided)
|
||||||
|
- Environment-specific configs (dev/staging/prod)
|
||||||
|
- Secret management strategy
|
||||||
|
- Configuration validation
|
||||||
|
|
||||||
|
### 4. Deployment Configuration
|
||||||
|
- Infrastructure as Code (Terraform, Pulumi, etc.)
|
||||||
|
- Deployment strategy (blue-green, rolling, canary)
|
||||||
|
- Rollback capability
|
||||||
|
- Database migration strategy
|
||||||
|
|
||||||
|
### 5. Monitoring & Logging
|
||||||
|
- Application logging implementation
|
||||||
|
- Error tracking (Sentry, etc.)
|
||||||
|
- Health check endpoints
|
||||||
|
- Metrics collection
|
||||||
|
- Alerting configuration
|
||||||
|
|
||||||
|
### 6. Backup & Recovery
|
||||||
|
- Database backup strategy
|
||||||
|
- Disaster recovery plan
|
||||||
|
- Data retention policy
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
- `Glob`, `Read`: Config files
|
||||||
|
- `Bash`: Validate configs, check tool versions
|
||||||
|
- `Grep`: Search for logging/monitoring patterns
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
Final deliverable in **Korean (한국어)**.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Project Name] DevOps Review
|
||||||
|
|
||||||
|
## DevOps Score: [1-10]
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
- Pipeline: [present/absent]
|
||||||
|
- Stages: [list]
|
||||||
|
- Issues: ...
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
- Dockerfile: [present/absent]
|
||||||
|
- Quality: [score]
|
||||||
|
- Issues: ...
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
- .env handling: [SAFE/RISKY]
|
||||||
|
- Secret management: [description]
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
- Logging: [present/absent]
|
||||||
|
- Error tracking: [present/absent]
|
||||||
|
- Health checks: [present/absent]
|
||||||
|
|
||||||
|
## Critical Gaps
|
||||||
|
1. ...
|
||||||
|
2. ...
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
1. [Critical]
|
||||||
|
2. [Important]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brutal Analysis Principles
|
||||||
|
- **No sugar-coating**: No CI/CD in 2026 = amateur hour. Say it
|
||||||
|
- **Evidence required**: Reference specific config files
|
||||||
|
- **Never hide negative facts**: .env committed to git = CRITICAL
|
||||||
|
|
||||||
|
## Claude-Gemini Cross-Debate Protocol
|
||||||
|
Same protocol. Claude analyzes → Gemini reviews → debate → consensus only.
|
||||||
95
.claude/agents/dev-docs-sync.md
Normal file
95
.claude/agents/dev-docs-sync.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
name: dev-docs-sync
|
||||||
|
description: Documentation sync review. README/SPEC/API docs vs actual code sync, missing docs, stale docs, API contract consistency
|
||||||
|
---
|
||||||
|
|
||||||
|
# Documentation Sync Review Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
Verify that all documentation accurately reflects the current state of the code.
|
||||||
|
Answers: "Can a new developer onboard using these docs? Are they truthful?"
|
||||||
|
|
||||||
|
## Input
|
||||||
|
Receives an absolute directory path. Reads all markdown/doc files AND cross-references with source code.
|
||||||
|
|
||||||
|
## Analysis Framework
|
||||||
|
|
||||||
|
### 1. README Accuracy
|
||||||
|
- Setup instructions: do they actually work?
|
||||||
|
- Feature list: matches implemented features?
|
||||||
|
- Architecture description: matches actual structure?
|
||||||
|
- Environment variables: all documented?
|
||||||
|
|
||||||
|
### 2. API Documentation
|
||||||
|
- All endpoints documented?
|
||||||
|
- Request/response schemas match code?
|
||||||
|
- Error codes documented?
|
||||||
|
- Authentication requirements clear?
|
||||||
|
- API contract consistency (versioning, naming conventions)
|
||||||
|
|
||||||
|
### 3. SPEC/Design Documents
|
||||||
|
- Specs match implementation?
|
||||||
|
- Outdated design decisions still documented as current?
|
||||||
|
- Missing specs for implemented features?
|
||||||
|
|
||||||
|
### 4. Code Comments
|
||||||
|
- Misleading comments (code changed, comment didn't)
|
||||||
|
- TODO/FIXME/HACK inventory
|
||||||
|
- JSDoc/docstring accuracy
|
||||||
|
|
||||||
|
### 5. Configuration Documentation
|
||||||
|
- All config files explained?
|
||||||
|
- Default values documented?
|
||||||
|
- Deployment instructions complete?
|
||||||
|
|
||||||
|
### 6. CLAUDE.md / Project Instructions
|
||||||
|
- Accurate project description?
|
||||||
|
- Build/test commands correct?
|
||||||
|
- Dependencies listed correctly?
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
- `Glob`, `Read`: Doc files and source code
|
||||||
|
- `Grep`: Cross-reference doc claims with code
|
||||||
|
- `Bash`: Test setup instructions if safe
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
Final deliverable in **Korean (한국어)**.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Project Name] Documentation Sync Review
|
||||||
|
|
||||||
|
## Docs Score: [1-10]
|
||||||
|
|
||||||
|
## README Issues
|
||||||
|
| Claim | Reality | File | Status |
|
||||||
|
|-------|---------|------|--------|
|
||||||
|
| | | | STALE/MISSING/WRONG |
|
||||||
|
|
||||||
|
## API Doc Gaps
|
||||||
|
| Endpoint | Documented? | Accurate? | Issue |
|
||||||
|
|----------|------------|-----------|-------|
|
||||||
|
|
||||||
|
## Stale/Misleading Content
|
||||||
|
| Doc File | Line | Issue |
|
||||||
|
|----------|------|-------|
|
||||||
|
|
||||||
|
## TODO/FIXME Inventory
|
||||||
|
| Tag | File:Line | Content | Age |
|
||||||
|
|-----|-----------|---------|-----|
|
||||||
|
|
||||||
|
## Missing Documentation
|
||||||
|
1. [What's missing]
|
||||||
|
2. ...
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
1. [Critical — blocks onboarding]
|
||||||
|
2. [Important — causes confusion]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brutal Analysis Principles
|
||||||
|
- **No sugar-coating**: Stale docs are worse than no docs — they actively mislead
|
||||||
|
- **Evidence required**: Cross-reference doc claims with actual code
|
||||||
|
- **Never hide negative facts**: If README setup instructions don't work, that's CRITICAL
|
||||||
|
|
||||||
|
## Claude-Gemini Cross-Debate Protocol
|
||||||
|
Same protocol. Claude analyzes → Gemini reviews → debate → consensus only.
|
||||||
113
.claude/agents/dev-idea-alignment.md
Normal file
113
.claude/agents/dev-idea-alignment.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
name: dev-idea-alignment
|
||||||
|
description: Cross-references idea analysis recommendations with actual implementation. Checks what was recommended vs what was built, identifies gaps and deviations
|
||||||
|
---
|
||||||
|
|
||||||
|
# Idea-to-Implementation Alignment Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
Verify whether business idea analysis findings and recommendations are actually reflected in the development project.
|
||||||
|
Answers: "Did you build what the analysis told you to build? What's missing? What deviated?"
|
||||||
|
|
||||||
|
## Input
|
||||||
|
1. **Analysis directory path** — containing all idea evaluation reports (market-intel, risk-guard, growth-hacker, sales-validator, biz-tech, ops-launcher, fortify, comprehensive)
|
||||||
|
2. **Project directory path** — the actual development project
|
||||||
|
|
||||||
|
## Analysis Framework
|
||||||
|
|
||||||
|
### 1. Tech Stack Alignment
|
||||||
|
- Recommended stack (from biz-tech agent) vs actual stack
|
||||||
|
- If different: is the deviation justified or problematic?
|
||||||
|
- Framework choices, database, infrastructure
|
||||||
|
|
||||||
|
### 2. MVP Feature Alignment
|
||||||
|
- Must-have features (from biz-tech/mvp-scoping) — implemented? partially? missing?
|
||||||
|
- Should-have features — any premature implementation?
|
||||||
|
- Won't-have features — any scope creep into v2 features?
|
||||||
|
|
||||||
|
### 3. Business Model Implementation
|
||||||
|
- Pricing tiers (from sales-validator) — reflected in code?
|
||||||
|
- Free/paid gates implemented?
|
||||||
|
- Payment integration present?
|
||||||
|
- Subscription management
|
||||||
|
|
||||||
|
### 4. Risk Mitigation Implementation
|
||||||
|
- Security risks (from risk-guard) — addressed in code?
|
||||||
|
- Legal requirements (법정 양식, 면책조항) — implemented?
|
||||||
|
- Data security measures for sensitive data
|
||||||
|
- Platform dependency mitigations
|
||||||
|
|
||||||
|
### 5. Growth/Marketing Readiness
|
||||||
|
- SEO optimization (from growth-hacker) — meta tags, SSR, sitemap?
|
||||||
|
- Analytics/tracking implemented?
|
||||||
|
- Referral/viral loop mechanisms?
|
||||||
|
- Onboarding flow quality
|
||||||
|
|
||||||
|
### 6. Operational Readiness
|
||||||
|
- KPIs (from ops-launcher) — measurable in current code?
|
||||||
|
- Monitoring/logging for production
|
||||||
|
- Scaling preparation
|
||||||
|
- Backup/recovery mechanisms
|
||||||
|
|
||||||
|
### 7. Competitor Differentiation
|
||||||
|
- Top differentiation points (from fortify) — visible in product?
|
||||||
|
- Competitor weaknesses exploited?
|
||||||
|
- Unique features actually built?
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
- `Read`: Analysis reports + source code
|
||||||
|
- `Glob`, `Grep`: Search codebase for specific implementations
|
||||||
|
- `Bash`: Run project, check configs
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
Final deliverable in **Korean (한국어)**.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Project Name] 아이디어-구현 정합성 리포트
|
||||||
|
|
||||||
|
## 정합성 점수: [0-100]
|
||||||
|
|
||||||
|
## 1. 기술 스택 정합성
|
||||||
|
| 영역 | 분석 권고 | 실제 구현 | 일치 | 비고 |
|
||||||
|
|------|----------|----------|------|------|
|
||||||
|
|
||||||
|
## 2. MVP 기능 정합성
|
||||||
|
### Must-Have
|
||||||
|
| 기능 | 권고 | 구현 상태 | 완성도 |
|
||||||
|
|------|------|----------|--------|
|
||||||
|
| | | ✅/🔄/❌ | % |
|
||||||
|
|
||||||
|
### 스코프 크리프 (권고 외 구현)
|
||||||
|
| 기능 | 분석 분류 | 현재 상태 | 리스크 |
|
||||||
|
|------|----------|----------|--------|
|
||||||
|
|
||||||
|
## 3. BM 구현 상태
|
||||||
|
| 항목 | 권고 | 구현 | 상태 |
|
||||||
|
|------|------|------|------|
|
||||||
|
|
||||||
|
## 4. 리스크 대응 구현
|
||||||
|
| 리스크 | 권고 대응 | 구현 상태 |
|
||||||
|
|--------|----------|----------|
|
||||||
|
|
||||||
|
## 5. 성장 준비도
|
||||||
|
| 항목 | 권고 | 구현 | 상태 |
|
||||||
|
|------|------|------|------|
|
||||||
|
|
||||||
|
## 6. 핵심 괴리 TOP 5
|
||||||
|
1. [가장 큰 괴리]
|
||||||
|
2. ...
|
||||||
|
|
||||||
|
## 7. 즉시 조치 필요 사항
|
||||||
|
1. ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brutal Analysis Principles
|
||||||
|
- **No sugar-coating**: If the analysis said "Must Have X" and it's not built, that's a CRITICAL gap
|
||||||
|
- **Evidence required**: File:line references for implementations, report references for recommendations
|
||||||
|
- **Track scope creep**: Building Won't-Have features while Must-Have features are incomplete = RED FLAG
|
||||||
|
|
||||||
|
## Claude-Gemini Cross-Debate Protocol
|
||||||
|
1. Claude reads all analysis reports + scans codebase → alignment draft
|
||||||
|
2. Gemini reviews: `gemini -y -p "{alignment findings}" -o text`
|
||||||
|
3. Debate disagreements
|
||||||
|
4. Only agreed findings in final output
|
||||||
96
.claude/agents/dev-performance.md
Normal file
96
.claude/agents/dev-performance.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
name: dev-performance
|
||||||
|
description: Performance review agent. N+1 queries, memory patterns, bundle size, API response design, caching strategy, database indexing
|
||||||
|
---
|
||||||
|
|
||||||
|
# Performance Review Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
Identify performance bottlenecks and optimization opportunities.
|
||||||
|
Answers: "Will this code perform well under load? Where are the bottlenecks?"
|
||||||
|
|
||||||
|
## Input
|
||||||
|
Receives an absolute directory path. Analyzes source code for performance anti-patterns.
|
||||||
|
|
||||||
|
## Analysis Framework
|
||||||
|
|
||||||
|
### 1. Database & Query Patterns
|
||||||
|
- N+1 query detection (ORM usage patterns)
|
||||||
|
- Missing indexes (based on query patterns)
|
||||||
|
- Unbounded queries (no LIMIT/pagination)
|
||||||
|
- Raw query vs ORM efficiency
|
||||||
|
|
||||||
|
### 2. Memory & Resource Patterns
|
||||||
|
- Memory leak indicators (unclosed connections, event listener buildup)
|
||||||
|
- Large object creation in loops
|
||||||
|
- Unbounded caches
|
||||||
|
- Stream vs buffer for large data
|
||||||
|
|
||||||
|
### 3. Frontend Performance (if applicable)
|
||||||
|
- Bundle size analysis
|
||||||
|
- Unnecessary re-renders
|
||||||
|
- Image optimization
|
||||||
|
- Lazy loading implementation
|
||||||
|
- Code splitting
|
||||||
|
|
||||||
|
### 4. API Design
|
||||||
|
- Response payload size
|
||||||
|
- Pagination implementation
|
||||||
|
- Batch vs individual requests
|
||||||
|
- Compression (gzip/brotli)
|
||||||
|
|
||||||
|
### 5. Caching Strategy
|
||||||
|
- Cache layer presence and placement
|
||||||
|
- Cache invalidation strategy
|
||||||
|
- Cache hit ratio design
|
||||||
|
- CDN usage
|
||||||
|
|
||||||
|
### 6. Concurrency & Async
|
||||||
|
- Blocking operations in async context
|
||||||
|
- Parallel vs sequential execution where applicable
|
||||||
|
- Connection pooling
|
||||||
|
- Rate limiting implementation
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
- `Glob`, `Grep`, `Read`: Code analysis
|
||||||
|
- `Bash`: Run build tools, check bundle size
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
Final deliverable in **Korean (한국어)**.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Project Name] Performance Review
|
||||||
|
|
||||||
|
## Performance Score: [1-10]
|
||||||
|
|
||||||
|
## Database Issues
|
||||||
|
| Issue | File:Line | Impact | Fix |
|
||||||
|
|-------|-----------|--------|-----|
|
||||||
|
|
||||||
|
## Memory Concerns
|
||||||
|
| Pattern | File:Line | Risk |
|
||||||
|
|---------|-----------|------|
|
||||||
|
|
||||||
|
## Frontend (if applicable)
|
||||||
|
- Bundle size:
|
||||||
|
- Key issues:
|
||||||
|
|
||||||
|
## API Optimization
|
||||||
|
| Endpoint | Issue | Recommendation |
|
||||||
|
|----------|-------|---------------|
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
- Current strategy:
|
||||||
|
- Gaps:
|
||||||
|
|
||||||
|
## Top 5 Performance Hotspots
|
||||||
|
1. [file:line] — [issue] — [estimated impact]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brutal Analysis Principles
|
||||||
|
- **No sugar-coating**: N+1 in production = ticking time bomb. Say it
|
||||||
|
- **Evidence required**: File:line + estimated impact
|
||||||
|
- **Never hide negative facts**: Missing caching on hot paths is a critical finding
|
||||||
|
|
||||||
|
## Claude-Gemini Cross-Debate Protocol
|
||||||
|
Same protocol. Claude analyzes → Gemini reviews → debate → consensus only.
|
||||||
91
.claude/agents/dev-security.md
Normal file
91
.claude/agents/dev-security.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
name: dev-security
|
||||||
|
description: Security review agent. OWASP Top 10, secrets in code, dependency vulnerabilities, auth/authz patterns, input validation
|
||||||
|
---
|
||||||
|
|
||||||
|
# Security Review Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
Identify security vulnerabilities and weaknesses in the codebase.
|
||||||
|
Answers: "Can this code be exploited? What are the attack surfaces?"
|
||||||
|
|
||||||
|
## Input
|
||||||
|
Receives an absolute directory path. Scans all source files, configs, and environment files.
|
||||||
|
|
||||||
|
## Analysis Framework
|
||||||
|
|
||||||
|
### 1. Secrets Detection
|
||||||
|
- Hardcoded API keys, passwords, tokens
|
||||||
|
- .env files committed to repo
|
||||||
|
- Private keys in codebase
|
||||||
|
- Connection strings with credentials
|
||||||
|
|
||||||
|
### 2. OWASP Top 10
|
||||||
|
- Injection (SQL, NoSQL, OS command, LDAP)
|
||||||
|
- Broken authentication
|
||||||
|
- Sensitive data exposure
|
||||||
|
- XML External Entities (XXE)
|
||||||
|
- Broken access control
|
||||||
|
- Security misconfiguration
|
||||||
|
- Cross-Site Scripting (XSS)
|
||||||
|
- Insecure deserialization
|
||||||
|
- Using components with known vulnerabilities
|
||||||
|
- Insufficient logging & monitoring
|
||||||
|
|
||||||
|
### 3. Authentication & Authorization
|
||||||
|
- Auth implementation review
|
||||||
|
- Session management
|
||||||
|
- Password hashing algorithm
|
||||||
|
- JWT handling (expiration, validation)
|
||||||
|
- Role-based access control (RBAC) implementation
|
||||||
|
|
||||||
|
### 4. Input Validation
|
||||||
|
- User input sanitization
|
||||||
|
- File upload validation
|
||||||
|
- API parameter validation
|
||||||
|
- SQL parameterization
|
||||||
|
|
||||||
|
### 5. Configuration Security
|
||||||
|
- CORS configuration
|
||||||
|
- HTTPS enforcement
|
||||||
|
- Security headers
|
||||||
|
- Rate limiting
|
||||||
|
- Error handling (information leakage)
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
- `Glob`, `Grep`, `Read`: Code scanning
|
||||||
|
- `Bash`: Run security scanners if available (npm audit, cargo audit, etc.)
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
Final deliverable in **Korean (한국어)**.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Project Name] Security Review
|
||||||
|
|
||||||
|
## Security Score: [1-10]
|
||||||
|
## Critical Vulnerabilities: [count]
|
||||||
|
|
||||||
|
## Secrets Found
|
||||||
|
| Type | File:Line | Severity | Action |
|
||||||
|
|------|-----------|----------|--------|
|
||||||
|
|
||||||
|
## OWASP Findings
|
||||||
|
| Category | File:Line | Description | Severity | Fix |
|
||||||
|
|----------|-----------|-------------|----------|-----|
|
||||||
|
|
||||||
|
## Auth/Authz Issues
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## Recommendations (Critical First)
|
||||||
|
1. [CRITICAL] ...
|
||||||
|
2. [HIGH] ...
|
||||||
|
3. [MEDIUM] ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brutal Analysis Principles
|
||||||
|
- **No sugar-coating**: Security holes are security holes. No "minor concern" for critical vulns
|
||||||
|
- **Evidence required**: File:line for every finding
|
||||||
|
- **Never hide negative facts**: If secrets are in the repo, flag IMMEDIATELY
|
||||||
|
|
||||||
|
## Claude-Gemini Cross-Debate Protocol
|
||||||
|
Same protocol. Claude analyzes → Gemini reviews → debate → consensus only.
|
||||||
85
.claude/agents/dev-supply-chain.md
Normal file
85
.claude/agents/dev-supply-chain.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: dev-supply-chain
|
||||||
|
description: Dependency and supply chain review. Vulnerability scanning, license compliance (GPL etc.), package maintenance health, outdated packages
|
||||||
|
---
|
||||||
|
|
||||||
|
# Supply Chain & Dependency Review Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
Evaluate the health and risk of all third-party dependencies.
|
||||||
|
Answers: "Are our dependencies safe, legal, and maintained?"
|
||||||
|
|
||||||
|
## Input
|
||||||
|
Receives an absolute directory path. Reads package manifests (package.json, Cargo.toml, pubspec.yaml, requirements.txt, etc.)
|
||||||
|
|
||||||
|
## Analysis Framework
|
||||||
|
|
||||||
|
### 1. Vulnerability Scanning
|
||||||
|
- Known CVEs in dependencies
|
||||||
|
- Run `npm audit` / `cargo audit` / `pip audit` / equivalent
|
||||||
|
- Severity classification (critical, high, medium, low)
|
||||||
|
- Transitive dependency risks
|
||||||
|
|
||||||
|
### 2. License Compliance
|
||||||
|
- GPL/AGPL contamination risk (copyleft in commercial project)
|
||||||
|
- License compatibility matrix
|
||||||
|
- Unlicensed packages
|
||||||
|
- License obligation checklist
|
||||||
|
|
||||||
|
### 3. Package Maintenance Health
|
||||||
|
- Last update date per dependency
|
||||||
|
- GitHub stars/activity (proxy for maintenance)
|
||||||
|
- Deprecated packages
|
||||||
|
- Single-maintainer risk (bus factor)
|
||||||
|
|
||||||
|
### 4. Outdated Packages
|
||||||
|
- Major version behind count
|
||||||
|
- Security-relevant updates missed
|
||||||
|
- Breaking change risk assessment
|
||||||
|
|
||||||
|
### 5. Dependency Bloat
|
||||||
|
- Total dependency count (direct + transitive)
|
||||||
|
- Unused dependencies
|
||||||
|
- Overlapping functionality (multiple libs for same purpose)
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
- `Read`: Package manifests, lock files
|
||||||
|
- `Bash`: Run audit tools, check package info
|
||||||
|
- `Grep`: Search for imports/requires
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
Final deliverable in **Korean (한국어)**.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Project Name] Supply Chain Review
|
||||||
|
|
||||||
|
## Supply Chain Score: [1-10]
|
||||||
|
|
||||||
|
## Vulnerabilities
|
||||||
|
| Package | Version | CVE | Severity | Fix Version |
|
||||||
|
|---------|---------|-----|----------|-------------|
|
||||||
|
|
||||||
|
## License Issues
|
||||||
|
| Package | License | Risk | Action Required |
|
||||||
|
|---------|---------|------|-----------------|
|
||||||
|
|
||||||
|
## Maintenance Health
|
||||||
|
| Package | Last Updated | Status | Risk |
|
||||||
|
|---------|-------------|--------|------|
|
||||||
|
|
||||||
|
## Outdated (Major Behind)
|
||||||
|
| Package | Current | Latest | Behind |
|
||||||
|
|---------|---------|--------|--------|
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
1. [CRITICAL] ...
|
||||||
|
2. [HIGH] ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brutal Analysis Principles
|
||||||
|
- **No sugar-coating**: GPL in a commercial SaaS = legal time bomb. Say it
|
||||||
|
- **Evidence required**: CVE numbers, license names, dates
|
||||||
|
- **Never hide negative facts**: Abandoned dependencies must be flagged
|
||||||
|
|
||||||
|
## Claude-Gemini Cross-Debate Protocol
|
||||||
|
Same protocol. Claude analyzes → Gemini reviews → debate → consensus only.
|
||||||
100
.claude/agents/dev-test-coverage.md
Normal file
100
.claude/agents/dev-test-coverage.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
name: dev-test-coverage
|
||||||
|
description: Test quality review agent. Test coverage quality (not just %), edge cases, integration tests, mocking strategy, test reliability
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test Coverage & Quality Review Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
Evaluate the testing strategy, quality, and reliability of the test suite.
|
||||||
|
Answers: "Can we trust these tests? Do they catch real bugs?"
|
||||||
|
|
||||||
|
## Input
|
||||||
|
Receives an absolute directory path. Reads test files and analyzes test patterns.
|
||||||
|
|
||||||
|
## Analysis Framework
|
||||||
|
|
||||||
|
### 1. Test Presence & Structure
|
||||||
|
- Test directory organization
|
||||||
|
- Test file naming conventions
|
||||||
|
- Test runner configuration
|
||||||
|
- Test-to-source file mapping
|
||||||
|
|
||||||
|
### 2. Coverage Quality (not just %)
|
||||||
|
- Critical paths covered?
|
||||||
|
- Edge cases tested? (null, empty, boundary values)
|
||||||
|
- Error paths tested?
|
||||||
|
- Happy path vs unhappy path ratio
|
||||||
|
- Lines covered ≠ logic covered
|
||||||
|
|
||||||
|
### 3. Test Types
|
||||||
|
- Unit tests presence and quality
|
||||||
|
- Integration tests presence
|
||||||
|
- E2E tests presence
|
||||||
|
- API tests
|
||||||
|
- Appropriate level for each test
|
||||||
|
|
||||||
|
### 4. Mocking Strategy
|
||||||
|
- Over-mocking (testing mocks, not code)
|
||||||
|
- Under-mocking (tests depend on external services)
|
||||||
|
- Mock consistency with real implementations
|
||||||
|
- Test doubles quality (spy, stub, mock, fake)
|
||||||
|
|
||||||
|
### 5. Test Reliability
|
||||||
|
- Flaky test indicators (time-dependent, order-dependent)
|
||||||
|
- Test isolation (shared state between tests)
|
||||||
|
- Deterministic assertions
|
||||||
|
- Timeout handling
|
||||||
|
|
||||||
|
### 6. Test Maintenance
|
||||||
|
- Brittle tests (break on refactor, not on bug)
|
||||||
|
- Test readability (arrange-act-assert pattern)
|
||||||
|
- Test naming (describes behavior, not implementation)
|
||||||
|
- DRY vs readable tradeoff
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
- `Glob`, `Read`: Test files
|
||||||
|
- `Bash`: Run test suite, check coverage
|
||||||
|
- `Grep`: Search test patterns
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
Final deliverable in **Korean (한국어)**.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Project Name] Test Quality Review
|
||||||
|
|
||||||
|
## Test Score: [1-10]
|
||||||
|
|
||||||
|
## Coverage Overview
|
||||||
|
- Unit tests: [count] files, [coverage]%
|
||||||
|
- Integration tests: [count]
|
||||||
|
- E2E tests: [count]
|
||||||
|
|
||||||
|
## Untested Critical Paths
|
||||||
|
| Feature/Path | Risk Level | Why It Matters |
|
||||||
|
|-------------|-----------|---------------|
|
||||||
|
|
||||||
|
## Mocking Issues
|
||||||
|
| Test File | Issue | Impact |
|
||||||
|
|-----------|-------|--------|
|
||||||
|
|
||||||
|
## Flaky/Brittle Tests
|
||||||
|
| Test | File:Line | Issue |
|
||||||
|
|------|-----------|-------|
|
||||||
|
|
||||||
|
## Test Gaps (Priority)
|
||||||
|
1. [Critical — no test for core business logic]
|
||||||
|
2. [High — error paths untested]
|
||||||
|
3. [Medium — edge cases missing]
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
1. ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brutal Analysis Principles
|
||||||
|
- **No sugar-coating**: 0% test coverage = "THIS PROJECT HAS NO SAFETY NET"
|
||||||
|
- **Evidence required**: File references for all findings
|
||||||
|
- **Never hide negative facts**: Tests that test mocks instead of code are worse than no tests
|
||||||
|
|
||||||
|
## Claude-Gemini Cross-Debate Protocol
|
||||||
|
Same protocol. Claude analyzes → Gemini reviews → debate → consensus only.
|
||||||
78
.claude/skills/project-audit.md
Normal file
78
.claude/skills/project-audit.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Project Audit Skill
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
`/project-audit [absolute_path]` or "프로젝트 감사", "코드 리뷰"
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Runs all 8 dev review agents on a given directory path. Produces a unified audit report.
|
||||||
|
|
||||||
|
## Input
|
||||||
|
- Absolute directory path (e.g., `/Users/user/projects/my-app`)
|
||||||
|
- The path MUST exist and contain a development project
|
||||||
|
|
||||||
|
## Execution Workflow
|
||||||
|
|
||||||
|
### Step 1: Reconnaissance
|
||||||
|
- Scan directory structure (Glob)
|
||||||
|
- Identify project type (language, framework)
|
||||||
|
- Find entry points, configs, package manifests
|
||||||
|
|
||||||
|
### Step 2: Parallel Analysis (4 agents)
|
||||||
|
- `dev-architecture`: Structure and design
|
||||||
|
- `dev-code-quality`: Code smells and readability
|
||||||
|
- `dev-security`: Vulnerabilities and secrets
|
||||||
|
- `dev-supply-chain`: Dependencies and licenses
|
||||||
|
|
||||||
|
### Step 3: Parallel Analysis (4 agents, may use Step 2 context)
|
||||||
|
- `dev-performance`: Bottlenecks
|
||||||
|
- `dev-docs-sync`: Documentation accuracy
|
||||||
|
- `dev-devops`: CI/CD and deployment
|
||||||
|
- `dev-test-coverage`: Test quality
|
||||||
|
|
||||||
|
### Step 4: Unified Report
|
||||||
|
Merge all 8 agent results into a single audit document.
|
||||||
|
|
||||||
|
## Context Window Management (Layered Analysis)
|
||||||
|
For large projects, each agent follows this scan strategy:
|
||||||
|
1. **L1 (Always)**: Entry points, configs, package manifests, README
|
||||||
|
2. **L2 (Core)**: Core business logic, domain layer, API routes
|
||||||
|
3. **L3 (On demand)**: Utilities, helpers, generated code — only if L1/L2 findings indicate issues
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
Final deliverable in **Korean (한국어)**.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Project Name] 종합 감사 리포트
|
||||||
|
|
||||||
|
## 종합 건강 점수: [0-100]
|
||||||
|
|
||||||
|
## 요약 대시보드
|
||||||
|
| 영역 | 점수(/10) | 상태 | 핵심 이슈 |
|
||||||
|
|------|----------|------|----------|
|
||||||
|
| Architecture | | 🟢/🟡/🔴 | |
|
||||||
|
| Code Quality | | | |
|
||||||
|
| Security | | | |
|
||||||
|
| Supply Chain | | | |
|
||||||
|
| Performance | | | |
|
||||||
|
| Documentation | | | |
|
||||||
|
| DevOps | | | |
|
||||||
|
| Testing | | | |
|
||||||
|
|
||||||
|
## Critical Findings (즉시 조치)
|
||||||
|
1. ...
|
||||||
|
|
||||||
|
## 상세 보고서 링크
|
||||||
|
- [Architecture](./dev-architecture-report.md)
|
||||||
|
- [Code Quality](./dev-code-quality-report.md)
|
||||||
|
- ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brutal Analysis Principles
|
||||||
|
- Scores must reflect reality. A project with no tests and hardcoded secrets cannot score above 30
|
||||||
|
- Cross-reference findings between agents (e.g., security finding + missing test = compounded risk)
|
||||||
|
|
||||||
|
## Claude-Gemini Cross-Debate Protocol
|
||||||
|
Each agent step includes Claude-Gemini debate. The unified report is also Gemini-reviewed.
|
||||||
|
|
||||||
|
## Save Path
|
||||||
|
`[project_path]/audit/project_audit_[date].md` or user-specified location
|
||||||
69
.claude/skills/project-diff-review.md
Normal file
69
.claude/skills/project-diff-review.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Project Diff Review Skill
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
`/project-diff [absolute_path] [commit_range or PR#]` or "변경분 리뷰", "PR 리뷰"
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Targeted review of a specific commit range or PR. Only analyzes changed files against the existing audit baseline. Much faster than full audit.
|
||||||
|
|
||||||
|
## Input
|
||||||
|
- Absolute project path
|
||||||
|
- Commit range (e.g., `HEAD~3..HEAD`) or PR number
|
||||||
|
- Optional: previous audit report for delta comparison
|
||||||
|
|
||||||
|
## Execution Workflow
|
||||||
|
|
||||||
|
### Step 1: Identify Changes
|
||||||
|
- `git diff --name-only [range]` to get changed files
|
||||||
|
- Categorize changes: new files, modified files, deleted files
|
||||||
|
|
||||||
|
### Step 2: Targeted Analysis (only relevant agents)
|
||||||
|
- Changed source files → `dev-code-quality`, `dev-architecture`
|
||||||
|
- Changed security-related files → `dev-security`
|
||||||
|
- Changed package files → `dev-supply-chain`
|
||||||
|
- Changed test files → `dev-test-coverage`
|
||||||
|
- Changed docs → `dev-docs-sync`
|
||||||
|
- Changed CI/config → `dev-devops`
|
||||||
|
- Performance-sensitive changes → `dev-performance`
|
||||||
|
|
||||||
|
### Step 3: Impact Assessment
|
||||||
|
- Does this change improve or degrade each dimension?
|
||||||
|
- New technical debt introduced?
|
||||||
|
- Existing issues fixed?
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
Final deliverable in **Korean (한국어)**.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Project Name] Diff Review — [commit range]
|
||||||
|
|
||||||
|
## Changes Summary
|
||||||
|
- Files changed: [count]
|
||||||
|
- Lines added: [count]
|
||||||
|
- Lines removed: [count]
|
||||||
|
|
||||||
|
## Review Results
|
||||||
|
| File | Agent | Finding | Severity |
|
||||||
|
|------|-------|---------|----------|
|
||||||
|
| | | | |
|
||||||
|
|
||||||
|
## Health Score Impact
|
||||||
|
| Dimension | Before | After | Delta |
|
||||||
|
|-----------|--------|-------|-------|
|
||||||
|
|
||||||
|
## Approval Status
|
||||||
|
- [ ] Security: PASS/FAIL
|
||||||
|
- [ ] Architecture: PASS/FAIL
|
||||||
|
- [ ] Tests: PASS/FAIL
|
||||||
|
- [ ] Docs updated: PASS/FAIL
|
||||||
|
|
||||||
|
## Verdict: [✅ APPROVE / ⚠️ REQUEST CHANGES / 🔴 BLOCK]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brutal Analysis Principles
|
||||||
|
- A diff that adds code without tests should be flagged
|
||||||
|
- A diff that changes API without updating docs should be flagged
|
||||||
|
- Security regressions = automatic BLOCK
|
||||||
|
|
||||||
|
## Claude-Gemini Cross-Debate Protocol
|
||||||
|
For diff reviews, Claude analyzes → Gemini reviews the same diff → consensus on verdict.
|
||||||
61
.claude/skills/project-fix-plan.md
Normal file
61
.claude/skills/project-fix-plan.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Project Fix Plan Skill
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
`/project-fix [absolute_path]` or "수정 계획", "개선 플랜"
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Creates a prioritized action plan from audit findings. Groups fixes by urgency and effort.
|
||||||
|
|
||||||
|
## Priority Framework (Eisenhower Matrix for Code)
|
||||||
|
|
||||||
|
| | Low Effort | High Effort |
|
||||||
|
|---|-----------|-------------|
|
||||||
|
| **Critical** | 🔴 DO NOW (Sprint 0) | 🟠 PLAN (Sprint 1-2) |
|
||||||
|
| **Important** | 🟡 SCHEDULE (Sprint 1) | ⚪ BACKLOG |
|
||||||
|
|
||||||
|
## Categorization Rules
|
||||||
|
- **Critical**: Security vulnerabilities, data loss risk, production blockers
|
||||||
|
- **Important**: Architecture violations, performance bottlenecks, test gaps
|
||||||
|
- **Low Effort**: < 2 hours, single file change
|
||||||
|
- **High Effort**: > 1 day, multi-file refactor
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
Final deliverable in **Korean (한국어)**.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Project Name] Fix Plan
|
||||||
|
|
||||||
|
## 🔴 Sprint 0 — 즉시 (이번 주)
|
||||||
|
| # | Finding | File | Effort | Agent |
|
||||||
|
|---|---------|------|--------|-------|
|
||||||
|
| 1 | | | ~Xh | Security |
|
||||||
|
|
||||||
|
## 🟠 Sprint 1 — 계획 (다음 2주)
|
||||||
|
| # | Finding | Files | Effort | Agent |
|
||||||
|
|---|---------|-------|--------|-------|
|
||||||
|
|
||||||
|
## 🟡 Sprint 2 — 예정 (이번 달)
|
||||||
|
| # | Finding | Scope | Effort | Agent |
|
||||||
|
|---|---------|-------|--------|-------|
|
||||||
|
|
||||||
|
## ⚪ Backlog — 여유 시 진행
|
||||||
|
| # | Finding | Scope | Effort | Agent |
|
||||||
|
|---|---------|-------|--------|-------|
|
||||||
|
|
||||||
|
## Estimated Total Effort
|
||||||
|
- Sprint 0: ~X hours
|
||||||
|
- Sprint 1: ~X days
|
||||||
|
- Sprint 2: ~X days
|
||||||
|
- Backlog: ~X days
|
||||||
|
|
||||||
|
## Expected Score Improvement
|
||||||
|
| Dimension | Current | After Sprint 0 | After Sprint 1 |
|
||||||
|
|-----------|---------|----------------|----------------|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brutal Analysis Principles
|
||||||
|
- Critical security fixes cannot be pushed to backlog — ever
|
||||||
|
- Effort estimates must be realistic for a solo developer
|
||||||
|
|
||||||
|
## Claude-Gemini Cross-Debate Protocol
|
||||||
|
Priority classification disagreements are debated. Effort estimates are averaged.
|
||||||
68
.claude/skills/project-health-score.md
Normal file
68
.claude/skills/project-health-score.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Project Health Score Skill
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
`/project-health [absolute_path]` or "프로젝트 건강 점수", "헬스 스코어"
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Calculates a weighted health score (0-100) across all 8 dimensions from the project audit.
|
||||||
|
|
||||||
|
## Scoring Weights
|
||||||
|
|
||||||
|
| Dimension | Weight | Rationale |
|
||||||
|
|-----------|--------|-----------|
|
||||||
|
| Security | 20% | Vulnerabilities can kill a product |
|
||||||
|
| Architecture | 15% | Foundation determines long-term viability |
|
||||||
|
| Code Quality | 15% | Maintainability = team velocity |
|
||||||
|
| Testing | 15% | Safety net for changes |
|
||||||
|
| Supply Chain | 10% | Legal and security exposure |
|
||||||
|
| DevOps | 10% | Deployment reliability |
|
||||||
|
| Performance | 10% | User experience |
|
||||||
|
| Documentation | 5% | Onboarding and maintenance |
|
||||||
|
|
||||||
|
## Score Interpretation
|
||||||
|
|
||||||
|
| Range | Grade | Meaning |
|
||||||
|
|-------|-------|---------|
|
||||||
|
| 90-100 | A | Production-ready, well-maintained |
|
||||||
|
| 75-89 | B | Good, minor issues to address |
|
||||||
|
| 60-74 | C | Acceptable, significant improvements needed |
|
||||||
|
| 40-59 | D | Risky, major issues present |
|
||||||
|
| 0-39 | F | Critical, not safe for production |
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
Final deliverable in **Korean (한국어)**.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Project Name] Health Score
|
||||||
|
|
||||||
|
## Overall: [Score]/100 — Grade [A/B/C/D/F]
|
||||||
|
|
||||||
|
## Radar Chart Data
|
||||||
|
| Dimension | Score | Weight | Weighted |
|
||||||
|
|-----------|-------|--------|----------|
|
||||||
|
| Security | /10 | 20% | |
|
||||||
|
| Architecture | /10 | 15% | |
|
||||||
|
| Code Quality | /10 | 15% | |
|
||||||
|
| Testing | /10 | 15% | |
|
||||||
|
| Supply Chain | /10 | 10% | |
|
||||||
|
| DevOps | /10 | 10% | |
|
||||||
|
| Performance | /10 | 10% | |
|
||||||
|
| Documentation | /10 | 5% | |
|
||||||
|
| **Total** | | **100%** | **/100** |
|
||||||
|
|
||||||
|
## Trend (if previous audit exists)
|
||||||
|
| Dimension | Previous | Current | Delta |
|
||||||
|
|-----------|----------|---------|-------|
|
||||||
|
|
||||||
|
## Bottom 3 (Biggest Improvement Opportunities)
|
||||||
|
1. [Dimension] — [Score] — [Quick Win]
|
||||||
|
2. ...
|
||||||
|
3. ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brutal Analysis Principles
|
||||||
|
- Weighted score must be mathematically correct — no rounding in favor
|
||||||
|
- Grade F projects must be called out explicitly
|
||||||
|
|
||||||
|
## Claude-Gemini Cross-Debate Protocol
|
||||||
|
Score disagreements > 1 point per dimension are debated until consensus.
|
||||||
25
.github/workflows/ci.yml
vendored
Normal file
25
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- run: flutter pub get
|
||||||
|
|
||||||
|
- run: dart format --set-exit-if-changed .
|
||||||
|
|
||||||
|
- run: flutter analyze
|
||||||
|
|
||||||
|
- run: flutter test
|
||||||
239
ARCHITECTURE.md
Normal file
239
ARCHITECTURE.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# 아키텍처
|
||||||
|
|
||||||
|
Ascii Never Die의 시스템 설계 문서.
|
||||||
|
|
||||||
|
## 계층 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Presentation │
|
||||||
|
│ features/ (화면, 위젯, 컨트롤러) │
|
||||||
|
│ front / new_character / game / arena / settings │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ Domain │
|
||||||
|
│ core/engine/ (게임 로직 서비스) │
|
||||||
|
│ core/model/ (데이터 모델) │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ Data │
|
||||||
|
│ data/ (정적 게임 데이터) │
|
||||||
|
│ core/storage/ (세이브/설정 저장소) │
|
||||||
|
│ core/infrastructure/ (광고, IAP) │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
의존 방향: Presentation → Domain → Data (역방향 금지)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 핵심 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
ProgressLoop (타이머)
|
||||||
|
│
|
||||||
|
▼ tickOnce()
|
||||||
|
ProgressService.tick()
|
||||||
|
│
|
||||||
|
├─→ CombatTickService (전투 틱 처리)
|
||||||
|
├─→ LootHandler (전리품 처리)
|
||||||
|
├─→ ExpHandler (경험치/레벨업)
|
||||||
|
├─→ SkillService (스킬 진행)
|
||||||
|
├─→ QuestCompletionHandler(퀘스트 완료)
|
||||||
|
├─→ StoryService (스토리 진행)
|
||||||
|
└─→ GameMutations (상태 변경 적용)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
GameState (Stream)
|
||||||
|
│
|
||||||
|
├─→ UI 갱신 (StreamBuilder)
|
||||||
|
└─→ SaveManager 자동 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
### 전투 사이클
|
||||||
|
|
||||||
|
```
|
||||||
|
TaskGenerator.생성() → 몬스터 조우
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
CombatTickService.처리()
|
||||||
|
├─→ PlayerAttackProcessor (플레이어 공격)
|
||||||
|
├─→ CombatCalculator (데미지 계산)
|
||||||
|
└─→ 결과 판정
|
||||||
|
├─ 승리 → LootHandler → ExpHandler → 다음 태스크
|
||||||
|
└─ 패배 → DeathHandler → ResurrectionService
|
||||||
|
```
|
||||||
|
|
||||||
|
## 디렉토리별 책임
|
||||||
|
|
||||||
|
### `data/` -- 정적 게임 데이터
|
||||||
|
|
||||||
|
Config.dfm에서 추출한 종족, 직업, 스킬, 포션, 스토리 데이터. Dart const로 관리하며 런타임 변경 없음.
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `pq_config_data.dart` | 게임 원본 정적 데이터 (몬스터, 아이템, 주문 등) |
|
||||||
|
| `class_data.dart` | 직업 정의 + 특성 |
|
||||||
|
| `race_data.dart` | 종족 정의 + 특성 |
|
||||||
|
| `skill_data.dart` | 68개 스킬 정의 |
|
||||||
|
| `potion_data.dart` | 포션 데이터 |
|
||||||
|
| `story_data.dart` | 스토리/액트 데이터 |
|
||||||
|
| `game_text_l10n.dart` | 게임 텍스트 다국어 매핑 |
|
||||||
|
|
||||||
|
### `core/engine/` -- 게임 로직 (30개 서비스)
|
||||||
|
|
||||||
|
타이머 기반 메인 루프에서 호출되는 순수 게임 로직. UI 의존 없음.
|
||||||
|
|
||||||
|
| 서비스 | 역할 |
|
||||||
|
|--------|------|
|
||||||
|
| `progress_loop.dart` | 타이머 기반 메인 루프 (틱 발행) |
|
||||||
|
| `progress_service.dart` | 틱 수신 → 서비스 오케스트레이션 |
|
||||||
|
| `combat_tick_service.dart` | 전투 틱 처리 |
|
||||||
|
| `combat_calculator.dart` | 데미지/방어/크리티컬 계산 |
|
||||||
|
| `player_attack_processor.dart` | 플레이어 공격 처리 |
|
||||||
|
| `death_handler.dart` | 사망 처리 |
|
||||||
|
| `resurrection_service.dart` | 부활 처리 |
|
||||||
|
| `loot_handler.dart` | 전리품 드롭 |
|
||||||
|
| `exp_handler.dart` | 경험치/레벨업 |
|
||||||
|
| `item_service.dart` | 아이템 생성/비교 |
|
||||||
|
| `shop_service.dart` | 상점 매매 |
|
||||||
|
| `market_service.dart` | 시장 거래 |
|
||||||
|
| `skill_service.dart` | 스킬 진행/레벨업 |
|
||||||
|
| `skill_auto_selector.dart` | 스킬 자동 선택 |
|
||||||
|
| `potion_service.dart` | 포션 수집/사용 |
|
||||||
|
| `quest_completion_handler.dart` | 퀘스트 완료 처리 |
|
||||||
|
| `story_service.dart` | 스토리/액트 진행 |
|
||||||
|
| `act_progression_service.dart` | 액트 전환 |
|
||||||
|
| `arena_service.dart` | 아레나 랭킹/매칭 |
|
||||||
|
| `arena_combat_simulator.dart` | 아레나 전투 시뮬레이션 |
|
||||||
|
| `chest_service.dart` | 보물상자 처리 |
|
||||||
|
| `reward_service.dart` | 보상 분배 |
|
||||||
|
| `return_rewards_service.dart` | 복귀 보상 계산 |
|
||||||
|
| `stat_calculator.dart` | 스탯 총합 계산 |
|
||||||
|
| `task_generator.dart` | 태스크(전투/이동/상점) 생성 |
|
||||||
|
| `game_mutations.dart` | GameState 변경 함수 |
|
||||||
|
| `character_roll_service.dart` | 캐릭터 스탯 롤 (3d6) |
|
||||||
|
|
||||||
|
### `core/model/` -- 데이터 모델
|
||||||
|
|
||||||
|
freezed + json_serializable로 불변(immutable) 모델 생성. 직렬화/역직렬화 자동 처리.
|
||||||
|
|
||||||
|
주요 모델: `GameState`, `SaveData`, `CombatStats`, `EquipmentItem`, `ItemStats`, `MonetizationState`, `SkillSystemState`, `ProgressState`, `HallOfFame`
|
||||||
|
|
||||||
|
### `core/storage/` -- 저장 시스템
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `save_manager.dart` | 자동/수동 저장 관리 |
|
||||||
|
| `save_service.dart` | 세이브 슬롯 CRUD |
|
||||||
|
| `save_repository.dart` | 파일시스템 I/O |
|
||||||
|
| `save_integrity.dart` | HMAC-SHA256 무결성 검증 |
|
||||||
|
| `settings_repository.dart` | SharedPreferences 설정 |
|
||||||
|
| `hall_of_fame_storage.dart` | 명예의 전당 저장 |
|
||||||
|
| `statistics_storage.dart` | 통계 저장 |
|
||||||
|
|
||||||
|
### `core/infrastructure/` -- 외부 서비스
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `ad_service.dart` | Google AdMob 래퍼 (IAdService 구현) |
|
||||||
|
| `iap_service.dart` | in_app_purchase 래퍼 (IIAPService 구현) |
|
||||||
|
|
||||||
|
### `features/` -- 화면 (Presentation)
|
||||||
|
|
||||||
|
각 화면은 독립된 디렉토리. controllers/managers/pages/widgets로 세분화.
|
||||||
|
|
||||||
|
| 디렉토리 | 화면 | 구성 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `front/` | 타이틀/세이브 선택 | 프론트 스크린, 세이브 피커 |
|
||||||
|
| `new_character/` | 캐릭터 생성 | 종족/직업 선택, 스탯 롤, 이름 입력 |
|
||||||
|
| `game/` | 메인 게임 | 7개 탭 페이지, 모바일/데스크톱 레이아웃 |
|
||||||
|
| `arena/` | 아레나 PvP | 셋업, 전투, 결과 |
|
||||||
|
| `hall_of_fame/` | 명예의 전당 | 영웅 목록, 상세 정보 |
|
||||||
|
| `settings/` | 설정 | 사운드, 언어, 계정 |
|
||||||
|
|
||||||
|
### `shared/` -- 공용 컴포넌트
|
||||||
|
|
||||||
|
- `animation/` -- ASCII 아트 애니메이션 시스템 (Canvas 기반 렌더링, 캐릭터/몬스터/무기 프레임)
|
||||||
|
- `widgets/` -- 레트로 UI 위젯 (RetroButton, RetroPanel, RetroProgressBar, RetroDialog 등)
|
||||||
|
- `theme/` -- ASCII 컬러 팔레트, 레트로 테마 상수
|
||||||
|
|
||||||
|
## DI 구조
|
||||||
|
|
||||||
|
GetIt 서비스 로케이터 패턴으로 인터페이스 기반 의존성 주입.
|
||||||
|
|
||||||
|
```
|
||||||
|
core/di/
|
||||||
|
├── service_locator.dart # GetIt 인스턴스 + 등록
|
||||||
|
├── i_ad_service.dart # 광고 서비스 인터페이스
|
||||||
|
└── i_iap_service.dart # IAP 서비스 인터페이스
|
||||||
|
```
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// 등록 (main.dart에서 1회 호출)
|
||||||
|
sl.registerLazySingleton<IIAPService>(() => IAPService.createInstance());
|
||||||
|
sl.registerLazySingleton<IAdService>(() => AdService.createInstance());
|
||||||
|
|
||||||
|
// 사용
|
||||||
|
final iap = sl<IIAPService>();
|
||||||
|
```
|
||||||
|
|
||||||
|
인터페이스를 통해 테스트 시 목(mock) 교체 가능.
|
||||||
|
|
||||||
|
## 수익화 시스템
|
||||||
|
|
||||||
|
### IAP (인앱결제)
|
||||||
|
|
||||||
|
- 상품: `remove_ads_and` (광고 제거 + 프리미엄)
|
||||||
|
- 구매 상태: `flutter_secure_storage`에 암호화 저장
|
||||||
|
- 영수증 검증: Google Play RSA 서명 로컬 검증 (`pointycastle`)
|
||||||
|
- 앱 재설치 시 자동 복원 지원
|
||||||
|
- `MonetizationState` (freezed 모델)로 구매/광고 상태 통합 관리
|
||||||
|
|
||||||
|
### AdMob (광고)
|
||||||
|
|
||||||
|
- 리워드 광고: 속도 부스트, 복귀 보상 2배
|
||||||
|
- 인터스티셜 광고: 부활 시
|
||||||
|
- IAP 구매자는 모든 광고 자동 비활성화
|
||||||
|
- 릴리즈 빌드에서 치트 메뉴 완전 차단 (`kDebugMode` 가드)
|
||||||
|
|
||||||
|
## 저장 시스템
|
||||||
|
|
||||||
|
### 세이브 파일
|
||||||
|
|
||||||
|
- JSON 직렬화 (`SaveData` → `json_serializable`)
|
||||||
|
- HMAC-SHA256 체크섬으로 무결성 검증 (`save_integrity.dart`)
|
||||||
|
- 파일시스템 기반 저장 (`path_provider`)
|
||||||
|
- 자동 저장: 레벨업, 퀘스트 완료, 주기적 타이머
|
||||||
|
|
||||||
|
### 설정
|
||||||
|
|
||||||
|
- `SharedPreferences`로 경량 설정 저장 (사운드, 언어 등)
|
||||||
|
|
||||||
|
### 보안 저장
|
||||||
|
|
||||||
|
- IAP 구매 상태: `flutter_secure_storage` (플랫폼 키체인/키스토어)
|
||||||
|
|
||||||
|
## 테스트 전략
|
||||||
|
|
||||||
|
```
|
||||||
|
test/
|
||||||
|
├── core/
|
||||||
|
│ ├── engine/ # 게임 엔진 서비스 단위 테스트 (12개)
|
||||||
|
│ ├── model/ # 모델 직렬화/상태 테스트 (2개)
|
||||||
|
│ ├── storage/ # 저장소 테스트 (1개)
|
||||||
|
│ └── util/ # 유틸리티/밸런스 테스트 (3개)
|
||||||
|
├── features/ # 위젯/컨트롤러 테스트 (3개)
|
||||||
|
├── regression/ # 결정적 게임 시뮬레이션 회귀 테스트 (1개)
|
||||||
|
└── helpers/ # 목 팩토리, 테스트 셋업
|
||||||
|
```
|
||||||
|
|
||||||
|
### 테스트 원칙
|
||||||
|
|
||||||
|
- **엔진 로직 우선**: 게임 엔진 서비스에 집중 (전투, 아이템, 스킬, 상점, 아레나, 포션, 복귀보상)
|
||||||
|
- **결정적 시뮬레이션**: `DeterministicRandom`으로 동일 시드 → 동일 결과 보장
|
||||||
|
- **목 팩토리**: `test/helpers/mock_factories.dart`로 GameState, CombatStats 등 재사용 가능한 테스트 데이터 제공
|
||||||
|
- **회귀 테스트**: 전체 게임 루프를 N틱 시뮬레이션하여 밸런스 변경 감지
|
||||||
|
|
||||||
|
### 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter test # 전체 실행
|
||||||
|
flutter test test/core/engine/ # 엔진 테스트만
|
||||||
|
```
|
||||||
70
CHANGELOG.md
Normal file
70
CHANGELOG.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
프로젝트의 주요 변경 사항을 기록합니다.
|
||||||
|
형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- AdMob 미디에이션 지원 준비 (AppLovin MAX)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-03-30
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- IAP 로컬 영수증 RSA 서명 검증 (Google Play pointycastle)
|
||||||
|
- 구매 상태 SharedPreferences → flutter_secure_storage 전환
|
||||||
|
- 세이브 파일 HMAC-SHA256 무결성 체크섬 추가
|
||||||
|
- 릴리즈 빌드 치트 메뉴 완전 차단 (kDebugMode 가드)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- CI 파이프라인 (.github/workflows/ci.yml: format+analyze+test)
|
||||||
|
- ATT 추적 동의 문구 다국어화 (한/영/일)
|
||||||
|
- iOS Podfile 최소 버전 명시 (13.0)
|
||||||
|
- 복귀 보상 유료 유저 오프라인 시간 2배 인정
|
||||||
|
- IAP 앱 재설치 시 자동 구매 복원
|
||||||
|
- 테스트 105개 추가 (death_handler, item_service, shop_service, arena_service, potion_service, monetization_state, return_rewards_service)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- google_mobile_ads 5.3 → 7.x 업그레이드 (iOS Privacy Manifest 대응)
|
||||||
|
- flutter_lints 5 → 6 업그레이드
|
||||||
|
- IAP 상품 ID: remove_ads → remove_ads_and
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 인터스티셜 광고 실패 시 onComplete 콜백 미호출
|
||||||
|
- 아레나 DOT 스킬 INT/WIS가 ATK/DEF로 잘못 계산되던 문제
|
||||||
|
- ProgressState.copyWith(currentCombat: null) 초기화 안 되던 버그
|
||||||
|
- save_data JSON 캐스팅 시 null 크래시 방지
|
||||||
|
- settings_repository _prefs! 강제 언래핑 제거
|
||||||
|
- MonetizationState IAP 구매 상태 동기화 지연
|
||||||
|
- death_handler print() → debugPrint() 변경
|
||||||
|
- SpeedBoostButton 기본 배율 10→5 수정
|
||||||
|
- 복귀 보상 AdType 잘못된 타입 사용
|
||||||
|
|
||||||
|
### Refactored
|
||||||
|
- 데스크톱 레이아웃 → DesktopGameLayout 위젯 분리
|
||||||
|
- VictoryOverlay 734줄 → 265줄 (크레딧 콘텐츠 분리)
|
||||||
|
- HelpDialog 496줄 → 106줄 (4개 탭 뷰 분리)
|
||||||
|
- 미사용 StoryService 인스턴스 제거
|
||||||
|
- 중복 파일 제거 (arena/ascii_disintegrate_widget.dart)
|
||||||
|
- cupertino_icons 미사용 패키지 제거
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- CLAUDE.md Claude-Gemini 교차 토론 프로토콜 추가
|
||||||
|
- CLAUDE.md 존재하지 않는 디렉토리 제거
|
||||||
|
- 폰트 라이선스 파일 추가 (JetBrainsMono, PressStart2P)
|
||||||
|
- 스킬 개수 문서 수정 (70→68)
|
||||||
|
- 하드코딩 문자열 l10n 적용 (Undo/Rolls)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.1] - 2026-03-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- 초기 릴리즈 (Google Play 내부 테스트)
|
||||||
|
- 6개 화면 (프론트, 캐릭터 생성, 게임, 아레나, 명예의 전당, 설정)
|
||||||
|
- 광고 수익화 (AdMob 리워드/인터스티셜)
|
||||||
|
- IAP 광고 제거 + 프리미엄
|
||||||
|
- 한/영/일 3개 언어 지원
|
||||||
|
- 오프라인 완전 동작
|
||||||
118
CLAUDE.md
118
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## 프로젝트 개요
|
## 프로젝트 개요
|
||||||
|
|
||||||
Askii Never Die는 Progress Quest 6.4 (Delphi 원본)를 Flutter로 100% 동일하게 복제하는 오프라인 싱글플레이어 RPG입니다. 네트워크 기능은 모두 제외되며, 원본 알고리즘과 데이터를 그대로 유지해야 합니다.
|
Askii Never Die는 "디지털 판타지" 세계관의 오프라인 싱글플레이어 방치형 RPG입니다. ASCII 아트 비주얼과 자동 전투 시스템이 특징이며, 네트워크 기능은 제외됩니다.
|
||||||
|
|
||||||
## 빌드 및 실행
|
## 빌드 및 실행
|
||||||
|
|
||||||
@@ -27,29 +27,51 @@ flutter test
|
|||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
├── main.dart # 앱 진입점
|
├── main.dart # 앱 진입점
|
||||||
├── data/pq_config_data.dart # PQ 정적 데이터 (Config.dfm 추출)
|
├── data/ # 정적 데이터 (Config.dfm 추출 + 확장)
|
||||||
|
│ ├── pq_config_data.dart # 게임 원본 정적 데이터
|
||||||
|
│ ├── class_data.dart # 직업 데이터
|
||||||
|
│ ├── race_data.dart # 종족 데이터
|
||||||
|
│ ├── skill_data.dart # 스킬 데이터
|
||||||
|
│ ├── potion_data.dart # 포션 데이터
|
||||||
|
│ ├── story_data.dart # 스토리 데이터
|
||||||
|
│ └── game_text_l10n.dart # 게임 텍스트 번역
|
||||||
|
├── l10n/ # 앱 UI 다국어 리소스 (arb)
|
||||||
└── src/
|
└── src/
|
||||||
├── app.dart # MaterialApp 설정
|
├── app.dart # MaterialApp 설정
|
||||||
├── core/
|
├── core/
|
||||||
│ ├── engine/ # 게임 루프 및 진행 로직
|
│ ├── engine/ # 게임 루프 및 진행 로직
|
||||||
│ │ ├── progress_loop.dart # 타이머 기반 메인 루프 (원본 200ms)
|
│ │ ├── progress_loop.dart # 타이머 기반 메인 루프
|
||||||
│ │ ├── progress_service.dart # 틱 처리, 경험치/레벨업 로직
|
│ │ ├── progress_service.dart # 틱 처리, 경험치/레벨업 로직
|
||||||
│ │ ├── game_mutations.dart # 상태 변경 함수
|
│ │ ├── game_mutations.dart # 상태 변경 함수
|
||||||
│ │ └── reward_service.dart # 보상 처리
|
│ │ ├── reward_service.dart # 보상 처리
|
||||||
│ ├── model/
|
│ │ ├── combat_calculator.dart # 전투 계산
|
||||||
│ │ ├── game_state.dart # 핵심 상태: Traits, Stats, Inventory, Equipment, SpellBook, ProgressState, QueueState
|
│ │ ├── combat_tick_service.dart # 전투 틱 처리
|
||||||
│ │ ├── pq_config.dart # Config 데이터 접근
|
│ │ ├── arena_service.dart # 아레나 시스템
|
||||||
│ │ ├── equipment_slot.dart # 장비 슬롯 정의
|
│ │ ├── skill_service.dart # 스킬 시스템
|
||||||
│ │ └── save_data.dart # 저장 데이터 구조
|
│ │ ├── item_service.dart # 아이템 처리
|
||||||
│ ├── storage/ # 세이브 파일 처리
|
│ │ ├── potion_service.dart # 포션 시스템
|
||||||
│ └── util/
|
│ │ ├── shop_service.dart # 상점 시스템
|
||||||
│ ├── deterministic_random.dart # 결정론적 RNG (재현 가능)
|
│ │ ├── story_service.dart # 스토리 진행
|
||||||
│ ├── pq_logic.dart # 원본 로직 포팅 (odds, randSign 등)
|
│ │ └── ... # 기타 서비스
|
||||||
│ └── roman.dart # 로마 숫자 변환
|
│ ├── model/ # 게임 상태 및 데이터 모델
|
||||||
└── features/
|
│ ├── infrastructure/ # 외부 서비스 (광고, IAP 등)
|
||||||
├── front/front_screen.dart # 임시 프론트 화면
|
│ ├── audio/ # 오디오 서비스
|
||||||
└── game/game_session_controller.dart # 게임 세션 관리
|
│ ├── storage/ # 세이브/설정 저장소
|
||||||
|
│ ├── notification/ # 알림 서비스
|
||||||
|
│ └── util/ # 유틸리티 (RNG, 로직 헬퍼 등)
|
||||||
|
├── features/
|
||||||
|
│ ├── front/ # 타이틀/세이브 선택 화면
|
||||||
|
│ ├── new_character/ # 캐릭터 생성 화면
|
||||||
|
│ ├── game/ # 게임 진행 화면 (메인)
|
||||||
|
│ │ ├── controllers/ # 전투 로그, 오디오 컨트롤러
|
||||||
|
│ │ ├── managers/ # 통계, 부활, 속도 부스트 등
|
||||||
|
│ │ ├── pages/ # 탭별 페이지 (장비, 인벤토리, 퀘스트 등)
|
||||||
|
│ │ └── widgets/ # UI 위젯
|
||||||
|
│ ├── arena/ # 아레나 전투 화면
|
||||||
|
│ ├── hall_of_fame/ # 명예의 전당
|
||||||
|
│ └── settings/ # 설정 화면
|
||||||
|
└── shared/ # 공통 테마/위젯
|
||||||
|
|
||||||
example/pq/ # Delphi 원본 소스 (참조용, 빌드 대상 아님)
|
example/pq/ # Delphi 원본 소스 (참조용, 빌드 대상 아님)
|
||||||
test/ # 단위/위젯 테스트
|
test/ # 단위/위젯 테스트
|
||||||
@@ -69,10 +91,9 @@ test/ # 단위/위젯 테스트
|
|||||||
|
|
||||||
## 핵심 규칙
|
## 핵심 규칙
|
||||||
|
|
||||||
### 원본 충실도
|
### 원본 참조 정책
|
||||||
- `example/pq/` 내 Delphi 소스의 알고리즘/데이터를 100% 동일하게 포팅
|
- `example/pq/`는 참조용으로 유지
|
||||||
- 원본 로직 변경 필요 시 반드시 사용자 승인 필요
|
- 원본 알고리즘은 참고하되 독자적 확장/수정 허용
|
||||||
- 새로운 기능, 값, 처리 로직 추가 금지 (디버깅 로그 예외)
|
|
||||||
|
|
||||||
### 데이터 관리
|
### 데이터 관리
|
||||||
- 정적 데이터(몬스터, 아이템, 주문 등)는 `Config.dfm`에서 추출하여 JSON/Dart const로 관리
|
- 정적 데이터(몬스터, 아이템, 주문 등)는 `Config.dfm`에서 추출하여 JSON/Dart const로 관리
|
||||||
@@ -87,11 +108,13 @@ test/ # 단위/위젯 테스트
|
|||||||
- SRP(Single Responsibility Principle) 준수
|
- SRP(Single Responsibility Principle) 준수
|
||||||
|
|
||||||
### 화면 구성
|
### 화면 구성
|
||||||
- 2개 화면만 사용: 캐릭터 생성 화면, 게임 진행 화면
|
- 주요 화면: 프론트, 캐릭터 생성, 게임 진행, 아레나, 명예의 전당, 설정
|
||||||
- 화면 내 요소는 위젯 단위로 분리
|
- 화면 내 요소는 위젯 단위로 분리
|
||||||
|
|
||||||
## 원본 소스 참조 (example/pq/)
|
## 원본 소스 참조 (example/pq/)
|
||||||
|
|
||||||
|
> 참고용으로만 사용. 원본 로직을 그대로 따를 의무는 없음.
|
||||||
|
|
||||||
| 파일 | 핵심 함수/라인 | 역할 |
|
| 파일 | 핵심 함수/라인 | 역할 |
|
||||||
|------|----------------|------|
|
|------|----------------|------|
|
||||||
| `Main.pas:523-1040` | `MonsterTask` | 전투/전리품/레벨업 |
|
| `Main.pas:523-1040` | `MonsterTask` | 전투/전리품/레벨업 |
|
||||||
@@ -105,7 +128,6 @@ test/ # 단위/위젯 테스트
|
|||||||
- `pubspec.yaml` 의존성 변경
|
- `pubspec.yaml` 의존성 변경
|
||||||
- 플랫폼 빌드 설정 (Android/iOS/desktop)
|
- 플랫폼 빌드 설정 (Android/iOS/desktop)
|
||||||
- 네트워크 접근 도입
|
- 네트워크 접근 도입
|
||||||
- 원본 데이터/알고리즘 수정
|
|
||||||
- 대규모 파일 삭제 또는 구조 변경
|
- 대규모 파일 삭제 또는 구조 변경
|
||||||
|
|
||||||
## 커밋 규칙
|
## 커밋 규칙
|
||||||
@@ -117,3 +139,43 @@ type(scope): 한국어 설명
|
|||||||
```
|
```
|
||||||
|
|
||||||
Types: `feat`, `fix`, `refactor`, `test`, `docs`, `style`, `chore`, `perf`
|
Types: `feat`, `fix`, `refactor`, `test`, `docs`, `style`, `chore`, `perf`
|
||||||
|
|
||||||
|
## 작업 프로토콜
|
||||||
|
|
||||||
|
### 3자 교차 토론 (Three-Party Cross-Debate)
|
||||||
|
모든 에이전트/스킬 실행 결과는 Claude, Gemini, Codex 3자가 독립 분석 후 토론하여 합의된 결과만 사용자에게 제공한다.
|
||||||
|
|
||||||
|
| AI | 역할 | 초점 |
|
||||||
|
|----|------|------|
|
||||||
|
| **Claude** | 전략가/종합자 | 비즈니스 로직, 설계, 최종 종합 |
|
||||||
|
| **Gemini** | 논리 비평가 | 논리적 모순, UX 갭, 엣지 케이스 |
|
||||||
|
| **Codex** | 기술 감사자 | 구현 실현성, 인프라 제약, 보안, 코드 품질 |
|
||||||
|
|
||||||
|
```
|
||||||
|
[Round 1: 초안 + 병렬 리뷰]
|
||||||
|
1. Claude 에이전트 실행 → 초안 생성
|
||||||
|
2. 병렬 실행:
|
||||||
|
a. gemini -y -p "{초안 + 리뷰 프롬프트}" -o text
|
||||||
|
b. codex exec "{초안 + 리뷰 프롬프트}" --full-auto
|
||||||
|
3. Claude가 3자 관점 비교 → 합의/불일치 식별
|
||||||
|
|
||||||
|
[Round 2: 불일치 토론 (불일치 있을 때만)]
|
||||||
|
4. gemini -y -r latest -p "{불일치 + 반론}" -o text
|
||||||
|
5. codex exec "{전체 컨텍스트 + 불일치}" --full-auto
|
||||||
|
6. Claude 최종 종합
|
||||||
|
|
||||||
|
[합의 규칙]
|
||||||
|
- 합의 시: 합의 내용만 출력
|
||||||
|
- 미합의 시: "[NO CONSENSUS]" 표기 + 3자 의견 병기
|
||||||
|
```
|
||||||
|
|
||||||
|
### 에이전트/스킬 활용
|
||||||
|
- 에이전트 정의: `.claude/agents/` (origin에서 복사)
|
||||||
|
- 스킬 정의: `.claude/skills/` (origin에서 복사)
|
||||||
|
- 비사소한 작업은 반드시 에이전트를 병렬 배포하여 진행
|
||||||
|
- 작업 완료 조건: Gemini 토론 합의 완료
|
||||||
|
|
||||||
|
## 프로젝트 분석 리포트
|
||||||
|
|
||||||
|
- `analysis/full-audit-2026-03-27.md` — 9개 에이전트 전체 감사 (Health Score: 49/100)
|
||||||
|
- `analysis/supply-chain-review.md` — 의존성/라이선스/CVE 분석
|
||||||
|
|||||||
250
PLAN.md
Normal file
250
PLAN.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# 종족/클래스 패시브 미반영 수정 계획
|
||||||
|
|
||||||
|
## 1. 현황 분석
|
||||||
|
|
||||||
|
### 반영되는 패시브 (전투 스탯 계산에 적용됨)
|
||||||
|
- HP/MP 보너스, 물리/마법 데미지 보너스, 방어력/회피율/크리티컬 보너스
|
||||||
|
|
||||||
|
### 미반영 패시브 (정의만 있고 실제 로직에서 미사용)
|
||||||
|
|
||||||
|
| 패시브 | 영향받는 종족/클래스 | 수정 위치 |
|
||||||
|
|--------|---------------------|-----------|
|
||||||
|
| `expMultiplier` | Byte Human (+5%), Callback Seraph (+3%) | `progress_service.dart:387` |
|
||||||
|
| `firstStrikeBonus` | Pointer Assassin (1.5배) | `combat_tick_service.dart` |
|
||||||
|
| `multiAttack` | Refactor Monk | `combat_tick_service.dart` |
|
||||||
|
| `postCombatHeal` | Garbage Collector (+5%) | `progress_service.dart:279` |
|
||||||
|
| `healingBonus` | Debugger Paladin, Exception Handler, Null Checker | `potion_service.dart`, `skill_service.dart` |
|
||||||
|
| `deathEquipmentPreserve` | Coredump Undead | **특성 변경 필요** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 수정 내용
|
||||||
|
|
||||||
|
### 2.1 경험치 배율 (`expMultiplier`)
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/engine/progress_service.dart`
|
||||||
|
|
||||||
|
**위치**: 384-387줄
|
||||||
|
|
||||||
|
**현재 코드**:
|
||||||
|
```dart
|
||||||
|
if (gain && nextState.traits.level < 100 && monsterExpReward > 0) {
|
||||||
|
final newExpPos = progress.exp.position + monsterExpReward;
|
||||||
|
```
|
||||||
|
|
||||||
|
**수정 후**:
|
||||||
|
```dart
|
||||||
|
if (gain && nextState.traits.level < 100 && monsterExpReward > 0) {
|
||||||
|
// 종족 경험치 배율 적용 (예: Byte Human +5%)
|
||||||
|
final race = RaceData.findById(nextState.traits.raceId);
|
||||||
|
final expMultiplier = race?.expMultiplier ?? 1.0;
|
||||||
|
final adjustedExp = (monsterExpReward * expMultiplier).round();
|
||||||
|
final newExpPos = progress.exp.position + adjustedExp;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 첫 공격 배율 (`firstStrikeBonus`)
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/engine/combat_tick_service.dart`
|
||||||
|
|
||||||
|
**설계**:
|
||||||
|
- 전투 시작 시 첫 공격인지 추적하는 플래그 필요
|
||||||
|
- 첫 공격 시 `firstStrikeBonus` 배율 적용
|
||||||
|
|
||||||
|
**수정 방안**:
|
||||||
|
1. `CombatState`에 `isFirstAttack` 플래그 추가
|
||||||
|
2. `CombatTickService`에서 첫 플레이어 공격 시 배율 적용:
|
||||||
|
```dart
|
||||||
|
var damage = result.damage;
|
||||||
|
if (isFirstPlayerAttack && firstStrikeBonus > 1.0) {
|
||||||
|
damage = (damage * firstStrikeBonus).round();
|
||||||
|
isFirstPlayerAttack = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 연속 공격 (`multiAttack`)
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/engine/combat_tick_service.dart`
|
||||||
|
|
||||||
|
**설계**:
|
||||||
|
- `hasMultiAttack` 패시브가 있으면 일정 확률로 추가 공격
|
||||||
|
- 예: 30% 확률로 연속 공격 (2타)
|
||||||
|
|
||||||
|
**수정 방안**:
|
||||||
|
```dart
|
||||||
|
// 플레이어 공격 후
|
||||||
|
if (hasMultiAttack && rng.nextDouble() < 0.3) {
|
||||||
|
// 추가 공격 실행
|
||||||
|
final extraAttack = calculator.playerAttackMonster(...);
|
||||||
|
// 결과 합산
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 전투 후 HP 회복 (`postCombatHeal`)
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/engine/progress_service.dart`
|
||||||
|
|
||||||
|
**위치**: 276-280줄
|
||||||
|
|
||||||
|
**현재 코드**:
|
||||||
|
```dart
|
||||||
|
// 전투 승리 시 HP 회복 (50% + CON/2)
|
||||||
|
final conBonus = nextState.stats.con ~/ 2;
|
||||||
|
final healAmount = (maxHp * 0.5).round() + conBonus;
|
||||||
|
```
|
||||||
|
|
||||||
|
**수정 후**:
|
||||||
|
```dart
|
||||||
|
// 전투 승리 시 HP 회복 (50% + CON/2 + 클래스 패시브)
|
||||||
|
final conBonus = nextState.stats.con ~/ 2;
|
||||||
|
var healAmount = (maxHp * 0.5).round() + conBonus;
|
||||||
|
|
||||||
|
// 클래스 패시브: 전투 후 HP 회복 (Garbage Collector +5%)
|
||||||
|
final klass = ClassData.findById(nextState.traits.classId);
|
||||||
|
if (klass != null) {
|
||||||
|
final postCombatHealRate = klass.getPassiveValue(ClassPassiveType.postCombatHeal);
|
||||||
|
if (postCombatHealRate > 0) {
|
||||||
|
healAmount += (maxHp * postCombatHealRate).round();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5 회복력 보너스 (`healingBonus`)
|
||||||
|
|
||||||
|
물약/스킬 사용 시 추가 회복 적용
|
||||||
|
|
||||||
|
#### 2.5.1 물약 회복
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/engine/potion_service.dart`
|
||||||
|
|
||||||
|
**수정 위치**: `usePotion()` 메서드 (73-81줄)
|
||||||
|
|
||||||
|
**현재 코드**:
|
||||||
|
```dart
|
||||||
|
if (potion.isHpPotion) {
|
||||||
|
healedAmount = potion.calculateHeal(maxHp);
|
||||||
|
newHp = (currentHp + healedAmount).clamp(0, maxHp);
|
||||||
|
```
|
||||||
|
|
||||||
|
**수정 후**:
|
||||||
|
```dart
|
||||||
|
if (potion.isHpPotion) {
|
||||||
|
var baseHeal = potion.calculateHeal(maxHp);
|
||||||
|
// 회복력 보너스 적용 (클래스 패시브)
|
||||||
|
baseHeal = (baseHeal * healingMultiplier).round();
|
||||||
|
newHp = (currentHp + baseHeal).clamp(0, maxHp);
|
||||||
|
healedAmount = newHp - currentHp;
|
||||||
|
```
|
||||||
|
|
||||||
|
**참고**: `PotionService`에 `healingMultiplier` 파라미터 추가 필요
|
||||||
|
|
||||||
|
#### 2.5.2 스킬 회복
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/engine/skill_service.dart`
|
||||||
|
|
||||||
|
**수정 위치**: `useHealSkill()` 메서드 (125-132줄)
|
||||||
|
|
||||||
|
**현재 코드**:
|
||||||
|
```dart
|
||||||
|
int healAmount = skill.healAmount;
|
||||||
|
if (skill.healPercent > 0) {
|
||||||
|
healAmount += (player.hpMax * skill.healPercent).round();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**수정 후**:
|
||||||
|
```dart
|
||||||
|
int healAmount = skill.healAmount;
|
||||||
|
if (skill.healPercent > 0) {
|
||||||
|
healAmount += (player.hpMax * skill.healPercent).round();
|
||||||
|
}
|
||||||
|
// 회복력 보너스 적용 (클래스 패시브)
|
||||||
|
healAmount = (healAmount * healingMultiplier).round();
|
||||||
|
```
|
||||||
|
|
||||||
|
**참고**: `SkillService`에 `healingMultiplier` 파라미터 추가 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.6 Coredump Undead 특성 변경
|
||||||
|
|
||||||
|
**현재 특성**: `deathEquipmentPreserve` (사망 시 장비 1개 유지) - BM 침해
|
||||||
|
|
||||||
|
**대체 특성 제안** (언데드 콘셉트에 어울리는 것):
|
||||||
|
|
||||||
|
| 옵션 | 설명 | 장점 |
|
||||||
|
|------|------|------|
|
||||||
|
| **방어력 +10%** | 언데드는 고통을 느끼지 않아 피해 감소 | 구현 간단, CON+2와 시너지 |
|
||||||
|
| **HP +8%** | 불사의 육체 | 구현 간단, 생존형 콘셉트 유지 |
|
||||||
|
| **HP +5% + 방어력 +5%** | 복합 생존 특화 | 다른 종족과 차별화 |
|
||||||
|
|
||||||
|
**추천**: `defenseBonus: 0.10` (방어력 +10%)
|
||||||
|
- 이유: 언데드의 "고통을 느끼지 않는" 콘셉트와 어울림
|
||||||
|
- 기존 CON+2, STR+1 스탯과 탱커형 시너지
|
||||||
|
|
||||||
|
**파일**: `lib/data/race_data.dart`
|
||||||
|
|
||||||
|
**수정**:
|
||||||
|
```dart
|
||||||
|
static const coredumpUndead = RaceTraits(
|
||||||
|
raceId: 'coredump_undead',
|
||||||
|
name: 'Coredump Undead',
|
||||||
|
statModifiers: {
|
||||||
|
StatType.con: 2,
|
||||||
|
StatType.str: 1,
|
||||||
|
StatType.cha: -2,
|
||||||
|
StatType.dex: -1,
|
||||||
|
},
|
||||||
|
passives: [
|
||||||
|
PassiveAbility(
|
||||||
|
type: PassiveType.defenseBonus, // 변경
|
||||||
|
value: 0.10, // 변경
|
||||||
|
description: '방어력 +10%', // 변경
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 수정 순서
|
||||||
|
|
||||||
|
1. **Coredump Undead 특성 변경** - 단순 데이터 수정
|
||||||
|
2. **경험치 배율** - 간단한 로직 추가
|
||||||
|
3. **전투 후 HP 회복** - 간단한 로직 추가
|
||||||
|
4. **회복력 보너스** - 서비스 파라미터 수정 필요
|
||||||
|
5. **첫 공격 배율** - 전투 상태 추적 필요
|
||||||
|
6. **연속 공격** - 전투 로직 수정 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 검증 방법
|
||||||
|
|
||||||
|
1. `flutter analyze` 통과
|
||||||
|
2. 각 패시브가 적용된 종족/클래스로 캐릭터 생성
|
||||||
|
3. 실제 게임 플레이로 효과 확인:
|
||||||
|
- Byte Human: 경험치 +5% (레벨업 속도)
|
||||||
|
- Pointer Assassin: 첫 공격 1.5배 (전투 시작 데미지)
|
||||||
|
- Refactor Monk: 연속 공격 (추가 타격)
|
||||||
|
- Garbage Collector: 전투 후 +5% HP 회복
|
||||||
|
- Debugger Paladin: 물약/스킬 회복량 +10%
|
||||||
|
- Coredump Undead: 방어력 +10%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 삭제할 코드
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/model/race_traits.dart`
|
||||||
|
|
||||||
|
`PassiveType.deathEquipmentPreserve` enum 삭제 (사용되지 않음)
|
||||||
|
|
||||||
|
**파일**: `lib/src/core/engine/stat_calculator.dart`
|
||||||
|
|
||||||
|
`calculateDeathEquipmentPreserve()` 메서드 삭제 (사용되지 않음)
|
||||||
129
README.md
129
README.md
@@ -1,25 +1,128 @@
|
|||||||
# Ascii Never Die
|
# ASCII Never Die
|
||||||
|
|
||||||
Offline Flutter rebuild of **Progress Quest 6.4** (single-player only). Network features are stripped; all game data and saves live locally.
|
"디지털 판타지" 세계관의 오프라인 방치형 RPG. ASCII 아트 비주얼과 자동 전투 시스템으로, 켜두기만 하면 캐릭터가 성장합니다.
|
||||||
|
|
||||||
## Layout
|
## 스크린샷
|
||||||
- `lib/src/features/front/` – temporary front screen shell to hang the upcoming flow on.
|
|
||||||
- `doc/progress-quest-flutter-plan.md` – working plan/notes for the port.
|
<!-- TODO: 스크린샷 추가 -->
|
||||||
- `example/pq/` – original Delphi source/assets (reference only, not built).
|
| 타이틀 | 캐릭터 생성 | 게임 진행 |
|
||||||
|
|--------|-------------|-----------|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
| 아레나 | 명예의 전당 | 설정 |
|
||||||
|
|--------|-------------|------|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
|
||||||
|
- **방치형 자동 전투** -- 전투, 레벨업, 퀘스트, 스토리가 자동으로 진행
|
||||||
|
- **ASCII 아트 애니메이션** -- Canvas 기반 전투/탐험/마을 씬, 21개 종족별 캐릭터 프레임
|
||||||
|
- **깊은 캐릭터 빌드** -- 21개 종족, 18개 직업, 68개 스킬, 11개 장비 슬롯
|
||||||
|
- **로컬 아레나** -- 클리어한 캐릭터들끼리 1:1 전투, 장비 약탈
|
||||||
|
- **오프라인 플레이** -- 게임 플레이는 네트워크 불필요, 광고/결제 시에만 네트워크 사용
|
||||||
|
- **다국어 지원** -- 한국어, 영어, 일본어
|
||||||
|
|
||||||
|
## 게임 시스템
|
||||||
|
|
||||||
|
### 스탯
|
||||||
|
| 스탯 | 효과 |
|
||||||
|
|------|------|
|
||||||
|
| STR | 물리 공격력, 쳐내기 |
|
||||||
|
| CON | 방어력, 최대 HP, 방패 방어 |
|
||||||
|
| DEX | 크리티컬, 회피, 명중, 공격 속도 |
|
||||||
|
| INT | 마법 공격력, DoT 데미지, MP |
|
||||||
|
| WIS | 마법 방어력, DoT 틱 간격, MP |
|
||||||
|
| CHA | 상점 할인 (최대 15%), 희귀 아이템 드롭률 보정 |
|
||||||
|
|
||||||
|
### 장비 희귀도
|
||||||
|
Common → Uncommon → Rare → Epic → Legendary (스탯 1.0x ~ 3.0x)
|
||||||
|
|
||||||
|
### 스킬 시스템
|
||||||
|
68개 전투 스킬 (공격/회복/버프/디버프), 쿨타임 기반, 자동 선택 AI
|
||||||
|
|
||||||
|
### 수익화
|
||||||
|
- AdMob 리워드/인터스티셜 광고
|
||||||
|
- IAP 광고 제거 + 프리미엄 (영구 5배속, 되돌리기 3회, 보물 상자 10개, 오프라인 2배)
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
|
||||||
|
| 구분 | 기술 |
|
||||||
|
|------|------|
|
||||||
|
| 프레임워크 | Flutter 3.x / Dart 3.9+ |
|
||||||
|
| 상태 관리 | ValueNotifier + Stream |
|
||||||
|
| 코드 생성 | freezed + json_serializable |
|
||||||
|
| DI | GetIt (서비스 로케이터) |
|
||||||
|
| 광고 | google_mobile_ads 7.x |
|
||||||
|
| 인앱결제 | in_app_purchase 3.x |
|
||||||
|
| 보안 | flutter_secure_storage, HMAC-SHA256, RSA 서명 검증 |
|
||||||
|
| 오디오 | just_audio (BGM 11곡, SFX 10종) |
|
||||||
|
| 폰트 | JetBrainsMono (ASCII), PressStart2P (레트로 UI) |
|
||||||
|
|
||||||
|
## 빌드 및 실행
|
||||||
|
|
||||||
## Run
|
|
||||||
```bash
|
```bash
|
||||||
|
# 의존성 설치
|
||||||
flutter pub get
|
flutter pub get
|
||||||
|
|
||||||
|
# 코드 생성 (freezed/json_serializable)
|
||||||
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
|
# 실행 (-d macos, -d chrome, -d android 등)
|
||||||
flutter run
|
flutter run
|
||||||
```
|
|
||||||
|
|
||||||
Use any supported platform (`-d macos`, `-d chrome`, etc.); multi-platform scaffolding is enabled.
|
# 릴리즈 빌드 (난독화 포함)
|
||||||
|
flutter build appbundle --obfuscate --split-debug-info=build/debug-info
|
||||||
|
|
||||||
## Checks
|
# 검증
|
||||||
Run from repo root before handoff:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dart format --set-exit-if-changed .
|
dart format --set-exit-if-changed .
|
||||||
flutter analyze
|
flutter analyze
|
||||||
flutter test
|
flutter test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 프로젝트 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── main.dart # 앱 진입점 + 에러 핸들링
|
||||||
|
├── data/ # 정적 게임 데이터 (종족, 직업, 스킬, 포션, 스토리)
|
||||||
|
├── l10n/ # 앱 UI 다국어 리소스 (arb)
|
||||||
|
└── src/
|
||||||
|
├── app.dart # MaterialApp 설정
|
||||||
|
├── core/
|
||||||
|
│ ├── engine/ # 게임 루프, 전투, 보상, 스킬 등 30개 서비스
|
||||||
|
│ ├── model/ # GameState, 장비, 스킬, 전투 등 데이터 모델
|
||||||
|
│ ├── infrastructure/ # AdMob, IAP 외부 서비스
|
||||||
|
│ ├── di/ # GetIt 서비스 로케이터 + 인터페이스
|
||||||
|
│ ├── audio/ # BGM/SFX 오디오 서비스
|
||||||
|
│ ├── storage/ # 세이브/설정/통계 저장소
|
||||||
|
│ ├── logging/ # 로컬 에러 로거
|
||||||
|
│ └── util/ # RNG, 밸런스 상수, 로직 헬퍼
|
||||||
|
├── features/
|
||||||
|
│ ├── front/ # 타이틀/세이브 선택
|
||||||
|
│ ├── new_character/ # 캐릭터 생성
|
||||||
|
│ ├── game/ # 메인 게임 (탭별 페이지 + 위젯)
|
||||||
|
│ ├── arena/ # 아레나 PvP
|
||||||
|
│ ├── hall_of_fame/ # 명예의 전당
|
||||||
|
│ └── settings/ # 설정
|
||||||
|
└── shared/ # 테마, 레트로 위젯, ASCII 애니메이션
|
||||||
|
|
||||||
|
test/ # 단위/위젯/회귀 테스트 (276개)
|
||||||
|
analysis/ # 프로젝트 감사/시장 분석 리포트
|
||||||
|
```
|
||||||
|
|
||||||
|
아키텍처 상세는 [ARCHITECTURE.md](ARCHITECTURE.md) 참조.
|
||||||
|
|
||||||
|
## 릴리즈 이력
|
||||||
|
|
||||||
|
| 버전 | 날짜 | 주요 내용 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| 1.1.0 | 2026-03-30 | IAP RSA 검증, HMAC 세이브, CI, 아키텍처 개선, 테스트 276개 |
|
||||||
|
| 1.0.1 | 2026-03-19 | 초기 릴리즈 (Google Play 내부 테스트) |
|
||||||
|
|
||||||
|
전체 변경 이력은 [CHANGELOG.md](CHANGELOG.md) 참조.
|
||||||
|
|
||||||
|
## 라이선스
|
||||||
|
|
||||||
|
비공개 프로젝트. 무단 배포 금지.
|
||||||
|
|
||||||
|
폰트 라이선스: JetBrains Mono (SIL OFL 1.1), Press Start 2P (SIL OFL 1.1).
|
||||||
|
|||||||
97
analysis/fix-plan-2026-03-27.md
Normal file
97
analysis/fix-plan-2026-03-27.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Askii Never Die — 수정 계획 (Fix Plan)
|
||||||
|
|
||||||
|
> 작성일: 2026-03-27
|
||||||
|
> Claude-Gemini Cross-Debate 합의 완료
|
||||||
|
> 기준: full-audit-2026-03-27.md (Health Score: 49/100)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 합의된 조정 사항
|
||||||
|
|
||||||
|
| 항목 | Claude 초안 | Gemini 의견 | 합의 |
|
||||||
|
|------|------------|------------|------|
|
||||||
|
| Crashlytics 배치 | Phase 4 | Phase 1 전진 | **Phase 4 유지** (오프라인 정책 충돌, 로컬 로그 대안) |
|
||||||
|
| IAP 검증 | 로컬 RSA + Secure Storage | 서버 검증 권고 | **로컬 RSA + Secure Storage** (서버 인프라 없음) |
|
||||||
|
| Force Update | 미포함 | 추가 권고 | **미포함** (오프라인 앱에서 구현 불가) |
|
||||||
|
| Code Obfuscation | 미포함 | 추가 권고 | **Phase 1에 추가** (빌드 설정 확인) |
|
||||||
|
| 공수 | P1:2-3일, P3:1-2주 | P1:4-5일, P3:2-3주 | **P1:5일, P3:3주** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: 보안 강화 + DevOps 기반 (5일)
|
||||||
|
|
||||||
|
| # | 작업 | 근거 | 검증 방법 |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 1-1 | CI 파이프라인 구축 | DevOps 2/10 → 6/10 | push 시 format+analyze+test 자동 실행 |
|
||||||
|
| 1-2 | 폰트 라이선스 파일 추가 | Supply Chain: OFL 의무 | assets/fonts/에 LICENSE 존재 |
|
||||||
|
| 1-3 | IAP 로컬 영수증 RSA 검증 | Security: Lucky Patcher 방어 | 검증 실패 시 구매 거부 테스트 |
|
||||||
|
| 1-4 | 구매 상태 flutter_secure_storage 전환 | Security: 평문 저장 제거 | 기존 SharedPreferences 마이그레이션 테스트 |
|
||||||
|
| 1-5 | 세이브 파일 HMAC 체크섬 | Security: 변조 감지 | 변조 세이브 로드 거부 테스트 |
|
||||||
|
| 1-6 | 난독화 설정 확인/적용 | Security: APK 디컴파일 방어 | --obfuscate + --split-debug-info 빌드 성공 |
|
||||||
|
| 1-7 | 중복 파일 제거 | Code Quality: 100% 중복 | features/arena/widgets/ascii_disintegrate_widget.dart 삭제 |
|
||||||
|
| 1-8 | cupertino_icons 제거 | Supply Chain: 미사용 ~280KB | pubspec.yaml에서 삭제 + flutter pub get |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: 수익 보호 + 버전 업데이트 (5일)
|
||||||
|
|
||||||
|
| # | 작업 | 근거 | 검증 방법 |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 2-1 | AdMob 미디에이션 (AppLovin MAX) | Idea Alignment: 수익 보호 | AdMob 비활성 시 백업 네트워크 로드 |
|
||||||
|
| 2-2 | google_mobile_ads 5.3→7.x | Supply Chain: iOS Privacy Manifest | App Store 심사 통과 |
|
||||||
|
| 2-3 | freezed 2.x→3.x + build_runner 업데이트 | Supply Chain: 중단된 전이 의존성 | dart run build_runner build 성공 |
|
||||||
|
| 2-4 | CHANGELOG + 버전 태그 체계 | DevOps: 릴리즈 이력 | git tag 존재 |
|
||||||
|
| 2-5 | flutter_lints 5→6 업데이트 | Code Quality: 최신 lint | flutter analyze 통과 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: 아키텍처 개선 (3주, 점진적)
|
||||||
|
|
||||||
|
| # | 작업 | 근거 | 검증 방법 |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 3-1 | 핵심 서비스 인터페이스 정의 | Architecture: 인터페이스 0개 | abstract class 6개 이상 |
|
||||||
|
| 3-2 | DI 컨테이너 (GetIt) 도입 | Architecture: 싱글톤 6개 제거 | 싱글톤 0개, GetIt 등록 |
|
||||||
|
| 3-3 | ad/iap_service → core/infrastructure/ 이동 | Architecture: 엔진 프레임워크 오염 | core/engine/에 Flutter import 0개 |
|
||||||
|
| 3-4 | setState → ValueNotifier 세분화 | Performance: 50ms 전체 리빌드 | 프레임 드롭 측정 (before/after) |
|
||||||
|
| 3-5 | God Widget 분할 (arena_battle_screen) | Code Quality: 759 LOC | 400 LOC 이하 |
|
||||||
|
| 3-6 | progress_service.dart 분할 | Code Quality: 832 LOC | 각 서비스 200 LOC 이하 |
|
||||||
|
| 3-7 | 저장 시스템 테스트 작성 | Test Coverage: 0% | save/load/delete 라운드트립 테스트 |
|
||||||
|
| 3-8 | Model 직렬화 라운드트립 테스트 | Test Coverage: 0% | toJson→fromJson 동일성 검증 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: 콘텐츠/성장 (2주+)
|
||||||
|
|
||||||
|
| # | 작업 | 근거 | 검증 방법 |
|
||||||
|
|---|------|------|-----------|
|
||||||
|
| 4-1 | Analytics 연동 (Firebase/PostHog) | Idea Alignment: DAU/리텐션 | 이벤트 로깅 확인 |
|
||||||
|
| 4-2 | 크래시 리포팅 (로컬 로그 또는 Crashlytics) | DevOps: 크래시 추적 | 에러 발생 시 로그 저장 |
|
||||||
|
| 4-3 | 피벗 후 시장 재분석 | Idea Alignment: 22/100 | idea-market-intel 리포트 |
|
||||||
|
| 4-4 | README 전면 재작성 | Docs Sync: 극빈약 | 6개 화면, 수익화, 다국어 반영 |
|
||||||
|
| 4-5 | ARCHITECTURE.md 작성 | Docs Sync: 없음 | 계층 구조, 데이터 흐름 문서화 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 예상 점수 변화
|
||||||
|
|
||||||
|
| Phase 완료 | Architecture | Code Quality | Security | Performance | Test | Supply Chain | Docs | DevOps | 예상 총점 |
|
||||||
|
|-----------|:-----------:|:-----------:|:--------:|:-----------:|:----:|:-----------:|:----:|:------:|:---------:|
|
||||||
|
| 현재 (0%) | 5 | 6 | 5 | 7 | 5 | 6 | 5 | 2 | **49** |
|
||||||
|
| Phase 1 | 5 | 6.5 | 7.5 | 7 | 5.5 | 7 | 5 | 5 | **59** |
|
||||||
|
| Phase 2 | 5 | 7 | 7.5 | 7 | 5.5 | 8 | 5.5 | 6 | **64** |
|
||||||
|
| Phase 3 | 8 | 8 | 7.5 | 8 | 7 | 8 | 6 | 6 | **76** |
|
||||||
|
| Phase 4 | 8 | 8 | 7.5 | 8 | 7 | 8 | 8 | 7 | **80+** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 제외된 항목
|
||||||
|
|
||||||
|
| 항목 | 제외 사유 |
|
||||||
|
|------|----------|
|
||||||
|
| Force Update | 오프라인 앱에서 네트워크 기반 버전 체크 불가 |
|
||||||
|
| IAP 서버 검증 | 서버 인프라 없음. 로컬 RSA 검증으로 대체 |
|
||||||
|
| Crashlytics Phase 1 | 오프라인 정책 충돌. Phase 4에서 로컬 로그 대안 검토 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Claude-Gemini Cross-Debate 합의 완료 (2026-03-27)*
|
||||||
226
analysis/full-audit-2026-03-27.md
Normal file
226
analysis/full-audit-2026-03-27.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# Askii Never Die - Full Project Audit Report
|
||||||
|
|
||||||
|
**분석일**: 2026-03-27
|
||||||
|
**분석 도구**: 9개 개발 리뷰 에이전트 (Claude-Gemini 교차 토론 적용)
|
||||||
|
**프로젝트**: Askii Never Die (오프라인 방치형 Idle RPG, Flutter)
|
||||||
|
**규모**: 61,000 LOC / 224개 Dart 파일 / 247개 테스트 (전부 통과)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Health Score: 49 / 100
|
||||||
|
|
||||||
|
| # | 영역 | 점수 | 가중치 | 가중 점수 | 핵심 이슈 |
|
||||||
|
|---|------|:----:|:------:|:---------:|-----------|
|
||||||
|
| 1 | Architecture | 5/10 | 15% | 7.5 | 인터페이스 0개, DI 부재, 싱글톤 6개, 엔진에 프레임워크 직접 의존 |
|
||||||
|
| 2 | Code Quality | 6/10 | 15% | 9.0 | 200 LOC 초과 47.6%, God Widget 2개, 100% 중복 파일 1쌍 |
|
||||||
|
| 3 | Security | 5/10 | 15% | 7.5 | IAP 로컬 영수증 검증 미구현, 세이브 무결성 없음, 구매 상태 평문 저장 |
|
||||||
|
| 4 | Performance | 7/10 | 10% | 7.0 | 매 50ms setState 전체 리빌드, 서비스 객체 반복 생성, SFX 캐싱 부재 |
|
||||||
|
| 5 | Test Coverage | 5/10 | 10% | 5.0 | 파일 커버리지 10%, 저장/전투틱/Model 직렬화 테스트 전무 |
|
||||||
|
| 6 | Supply Chain | 6/10 | 10% | 6.0 | 폰트 라이선스 미포함, google_mobile_ads 2 메이저 뒤처짐 |
|
||||||
|
| 7 | Docs Sync | 5/10 | 5% | 2.5 | CLAUDE.md에 존재하지 않는 디렉토리 3개, README 극빈약 |
|
||||||
|
| 8 | DevOps | 2/10 | 10% | 2.0 | CI/CD 완전 부재, 릴리즈 프로세스 미정립 |
|
||||||
|
| 9 | Idea Alignment | 22/100 | 10% | 2.2 | 완전 피벗 (RPS 격투 -> Idle RPG), 피벗 후 시장 재분석 없음 |
|
||||||
|
| | **합계** | | **100%** | **48.7** | |
|
||||||
|
|
||||||
|
> **점수 해석**: 49점대는 "동작하는 프로토타입~알파" 수준. 제품으로서 기능하지만 프로덕션 배포에 필요한 품질/운영 기반이 미비.
|
||||||
|
|
||||||
|
> **참고**: Keystore Git 노출은 개인 Git 서버에서 사용자가 허가한 사항이므로 이슈에서 제외. IAP는 서버가 없는 구조이므로 로컬 검증 강화 방안으로 조정.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRITICAL Issues (즉시 조치 필요)
|
||||||
|
|
||||||
|
### 1. CI/CD 파이프라인 완전 부재 [DevOps]
|
||||||
|
- **영향**: 빌드/테스트/린트 모두 수동. 깨진 코드 push 가능
|
||||||
|
- **조치**: Gitea Actions 또는 GitHub Actions로 `dart format` + `flutter analyze` + `flutter test` 자동화
|
||||||
|
- **예상 공수**: 반나절
|
||||||
|
|
||||||
|
### 2. AdMob 미디에이션 미구현 [Idea Alignment + Security]
|
||||||
|
- **영향**: AdMob 계정 정지 = 수익 즉시 소멸 (인디 개발자 가장 흔한 수익 소멸 시나리오)
|
||||||
|
- **조치**: AppLovin MAX 또는 IronSource 연동
|
||||||
|
- **예상 공수**: 2-3일
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIGH Issues (단기 조치)
|
||||||
|
|
||||||
|
### 3. IAP 로컬 영수증 서명 검증 미구현 [Security]
|
||||||
|
- **파일**: `lib/src/core/engine/iap_service.dart:332-336`
|
||||||
|
- **영향**: 서버 없이도 로컬 서명 검증으로 캐주얼 크래킹(Lucky Patcher 등) 방어 가능
|
||||||
|
- **조치**:
|
||||||
|
- **Google Play**: `purchase.verificationData.localVerificationData`에서 서명된 JSON 추출 -> Google Play Console의 공개 키(Base64 RSA)로 `RSASSA-PKCS1-v1_5 + SHA1` 서명 검증. `pointycastle` 패키지 활용
|
||||||
|
- **Apple**: StoreKit 2의 JWS(JSON Web Signature) 토큰을 로컬에서 Apple 루트 인증서로 검증
|
||||||
|
- **구매 상태 저장**: `shared_preferences` -> `flutter_secure_storage` (Android Keystore / iOS Keychain)
|
||||||
|
- **공수**: 1-2일
|
||||||
|
- **한계**: 루팅+후킹 조합 공격은 로컬 검증으로 완전 차단 불가. 그러나 90%+ 캐주얼 크래킹 방어 가능
|
||||||
|
|
||||||
|
### 4. 구매 상태 평문 저장 [Security]
|
||||||
|
- **파일**: `iap_service.dart:65-66`, `monetization_state.dart:17-45`
|
||||||
|
- **조치**: shared_preferences -> flutter_secure_storage 전환 (3번 작업과 동시 진행)
|
||||||
|
- **공수**: 반나절
|
||||||
|
|
||||||
|
### 5. 세이브 파일 무결성 없음 [Security]
|
||||||
|
- **파일**: `save_service.dart:17-20`
|
||||||
|
- **조치**: HMAC-SHA256 체크섬 추가. 키는 앱 번들에 난독화 포함
|
||||||
|
- **공수**: 반나절
|
||||||
|
|
||||||
|
### 6. Analytics 부재 [Idea Alignment]
|
||||||
|
- **영향**: DAU, 리텐션, 광고 시청률, IAP 전환율 측정 불가
|
||||||
|
- **조치**: Firebase Analytics 또는 PostHog 연동
|
||||||
|
- **공수**: 1일
|
||||||
|
|
||||||
|
### 7. 인터페이스 0개 + DI 부재 [Architecture]
|
||||||
|
- **영향**: 테스트 격리 불가, 싱글톤 6개로 모킹 원천 봉쇄
|
||||||
|
- **조치**: GetIt 또는 생성자 주입 도입, 핵심 서비스 abstract class 정의
|
||||||
|
- **공수**: 3-5일 (점진적)
|
||||||
|
|
||||||
|
### 8. 매 50ms 전체 위젯 setState [Performance]
|
||||||
|
- **파일**: `game_play_screen.dart:352-368`
|
||||||
|
- **영향**: 모바일 프레임 드롭 원인 1순위
|
||||||
|
- **조치**: ValueNotifier/Selector로 세분화된 상태 관리
|
||||||
|
- **공수**: 1-2일
|
||||||
|
|
||||||
|
### 9. 저장 시스템 테스트 전무 [Test Coverage]
|
||||||
|
- **파일**: `save_repository.dart`, `save_service.dart`, `save_manager.dart` (6개 파일 모두)
|
||||||
|
- **영향**: 세이브 파일 손상 시 플레이어 데이터 영구 손실
|
||||||
|
- **조치**: IOOverrides/임시 디렉토리 기반 저장/로드/삭제 테스트 작성
|
||||||
|
- **공수**: 1일
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MEDIUM Issues (중기 개선)
|
||||||
|
|
||||||
|
### 10. 폰트 라이선스 파일 미포함 [Supply Chain]
|
||||||
|
- JetBrainsMono (OFL), PressStart2P (OFL) LICENSE 파일 `assets/fonts/`에 추가 필수
|
||||||
|
|
||||||
|
### 11. google_mobile_ads 5.3.0 -> 7.0.0 업그레이드 [Supply Chain]
|
||||||
|
- iOS Privacy Manifest 미대응으로 App Store 심사 거절 리스크
|
||||||
|
|
||||||
|
### 12. God Widget 2개 분할 [Code Quality]
|
||||||
|
- `game_play_screen.dart` (736 LOC), `arena_battle_screen.dart` (759 LOC)
|
||||||
|
|
||||||
|
### 13. progress_service.dart 832 LOC 분할 [Code Quality]
|
||||||
|
- Quest/Plot/Exp/Combat 서비스로 분리
|
||||||
|
|
||||||
|
### 14. 100% 중복 파일 제거 [Code Quality]
|
||||||
|
- `features/arena/widgets/ascii_disintegrate_widget.dart` 삭제 (shared/ 버전 사용)
|
||||||
|
|
||||||
|
### 15. CLAUDE.md 디렉토리 구조 수정 [Docs Sync]
|
||||||
|
- `core/animation/` -> `shared/animation/`, `core/constants/` 삭제, `core/l10n/` -> `shared/l10n/`
|
||||||
|
|
||||||
|
### 16. README 전면 재작성 [Docs Sync]
|
||||||
|
- 현재 프로젝트 초기 임시 메모 수준. 6개 화면, 수익화, 다국어 지원 등 전혀 반영 안 됨
|
||||||
|
|
||||||
|
### 17. 크래시 리포팅 도입 [DevOps]
|
||||||
|
- Firebase Crashlytics 또는 Sentry
|
||||||
|
|
||||||
|
### 18. 엔진 계층 프레임워크 오염 제거 [Architecture]
|
||||||
|
- `ad_service.dart`, `iap_service.dart` 등을 `core/infrastructure/`로 이동
|
||||||
|
|
||||||
|
### 19. 피벗 후 시장 재분석 [Idea Alignment]
|
||||||
|
- "오프라인 Idle RPG + ASCII 아트" 시장 분석이 전무. idea-market-intel 재실행 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOW Issues (장기)
|
||||||
|
|
||||||
|
- cupertino_icons 미사용 제거 (~280KB 절약)
|
||||||
|
- SFX 에셋 프리로드 및 캐싱
|
||||||
|
- 모바일 포그라운드 복귀 시 전체 화면 재생성 최적화
|
||||||
|
- gcd_simulation_test/balance_analysis_test를 실제 assert 포함 테스트로 전환
|
||||||
|
- analysis_options.yaml lint rule 추가 (prefer_const_constructors, prefer_final_locals 등)
|
||||||
|
- CHANGELOG에 실제 릴리즈 이력 기록
|
||||||
|
- Model 직렬화 라운드트립 테스트 작성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix Plan (Eisenhower Matrix)
|
||||||
|
|
||||||
|
```
|
||||||
|
긴급 (Urgent) 비긴급 (Not Urgent)
|
||||||
|
┌──────────────────────────┬──────────────────────────┐
|
||||||
|
중요 │ DO FIRST (즉시) │ SCHEDULE (계획) │
|
||||||
|
(Important) │ │ │
|
||||||
|
│ 1. CI/CD 구축 │ 7. 인터페이스+DI 도입 │
|
||||||
|
│ 2. AdMob 미디에이션 │ 12. God Widget 분할 │
|
||||||
|
│ 3. IAP 로컬 서명 검증 │ 13. progress_service 분할│
|
||||||
|
│ 4. 구매 상태 보안 저장 │ 18. 엔진 프레임워크 분리 │
|
||||||
|
│ 5. 세이브 무결성 추가 │ 19. 피벗 후 시장 재분석 │
|
||||||
|
│ 10. 폰트 라이선스 추가 │ 17. 크래시 리포팅 도입 │
|
||||||
|
├──────────────────────────┼──────────────────────────┤
|
||||||
|
덜 중요 │ DELEGATE (위임 가능) │ ELIMINATE (후순위) │
|
||||||
|
(Less │ │ │
|
||||||
|
Important) │ 6. Analytics 연동 │ 14. 중복 파일 제거 │
|
||||||
|
│ 8. setState 세분화 │ 15. CLAUDE.md 수정 │
|
||||||
|
│ 9. 저장 시스템 테스트 │ 16. README 재작성 │
|
||||||
|
│ 11. google_mobile_ads 7.x│ + LOW 이슈 전체 │
|
||||||
|
└──────────────────────────┴──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실행 로드맵 (권장 순서)
|
||||||
|
|
||||||
|
### Phase 1: 보안 강화 + DevOps 기반 (2-3일)
|
||||||
|
```
|
||||||
|
1. CI 파이프라인 구축 (dart format + analyze + test) → verify: push 시 자동 실행
|
||||||
|
2. 폰트 라이선스 파일 추가 → verify: assets/fonts/에 LICENSE.txt 존재
|
||||||
|
3. IAP 로컬 영수증 서명 검증 구현 (Google Play 공개키 RSA 검증) → verify: 검증 실패 시 구매 거부 테스트
|
||||||
|
4. 구매 상태 flutter_secure_storage 전환 → verify: 기존 테스트 통과
|
||||||
|
5. 세이브 파일 HMAC 체크섬 추가 → verify: 변조된 세이브 로드 거부 테스트
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: 수익 보호 (3-5일)
|
||||||
|
```
|
||||||
|
1. AdMob 미디에이션 연동 (AppLovin MAX) → verify: AdMob 비활성 시 백업 네트워크 로드
|
||||||
|
2. google_mobile_ads 7.x 마이그레이션 → verify: iOS Privacy Manifest 포함 확인
|
||||||
|
3. CHANGELOG 정리 + 버전 태그 체계 수립 → verify: git tag 존재
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: 아키텍처 개선 (1-2주, 점진적)
|
||||||
|
```
|
||||||
|
1. 핵심 서비스 인터페이스 정의 (SaveRepository, AdService, IAPService)
|
||||||
|
2. DI 컨테이너(GetIt) 도입
|
||||||
|
3. ad_service, iap_service를 core/infrastructure/로 이동
|
||||||
|
4. GamePlayScreen setState 세분화
|
||||||
|
5. 저장 시스템 테스트 작성
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: 콘텐츠/성장 (2주+)
|
||||||
|
```
|
||||||
|
1. Firebase Analytics 연동
|
||||||
|
2. Crashlytics 연동
|
||||||
|
3. 피벗 후 시장 재분석 (idea-market-intel 실행)
|
||||||
|
4. Web meta 태그 수정 + ASO 최적화
|
||||||
|
5. README / CLAUDE.md / ARCHITECTURE.md 동기화
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 에이전트별 상세 리포트 참조
|
||||||
|
|
||||||
|
| 에이전트 | 점수 | 주요 발견 |
|
||||||
|
|---------|:----:|----------|
|
||||||
|
| Architecture | 5/10 | 인터페이스 0개, 싱글톤 6개, core/engine/ Flutter 오염 6파일, app.dart God Class 459 LOC |
|
||||||
|
| Code Quality | 6/10 | dart format/analyze 통과, 200 LOC 초과 100개(47.6%), God Widget 2개, var 31회 남용 |
|
||||||
|
| Security | 5/10 | IAP 로컬 서명 검증 미구현, 세이브 무결성 없음, 구매 상태 평문 저장 |
|
||||||
|
| Supply Chain | 6/10 | CVE 0, GPL 0, 폰트 라이선스 미포함, google_mobile_ads 2메이저 뒤처짐 |
|
||||||
|
| Performance | 7/10 | 50ms setState 전체 리빌드, 서비스 객체 매틱 재생성, SFX 캐싱 부재, RepaintBoundary 활용 양호 |
|
||||||
|
| Test Coverage | 5/10 | 247개 테스트 전통과, 파일 커버리지 10%, 저장/전투틱/Model 직렬화 테스트 전무 |
|
||||||
|
| Docs Sync | 5/10 | CLAUDE.md 존재하지 않는 경로 3개, README 극빈약, CHANGELOG 릴리즈 이력 없음 |
|
||||||
|
| DevOps | 2/10 | CI/CD 완전 부재, 릴리즈 프로세스 미정립, Crashlytics 없음 |
|
||||||
|
| Idea Alignment | 22/100 | 완전 피벗 (RPS -> Idle RPG), 수익화만 일치, 피벗 후 시장 재분석 없음, Analytics 부재 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 제외된 이슈
|
||||||
|
|
||||||
|
| 이슈 | 제외 사유 |
|
||||||
|
|------|----------|
|
||||||
|
| Keystore + 비밀번호 Git 노출 | 개인 Git 서버(Gitea)에서 사용자가 허가한 운영 방식 |
|
||||||
|
| IAP 서버 측 영수증 검증 | 서버 없는 오프라인 앱 구조. 로컬 서명 검증으로 대체 권고 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 보고서는 Claude-Gemini 교차 토론을 통해 합의된 결과입니다.*
|
||||||
|
*9개 에이전트 병렬 실행, 총 분석 시간: ~6분*
|
||||||
72
analysis/market-analysis-2026-03-30.md
Normal file
72
analysis/market-analysis-2026-03-30.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Askii Never Die — 시장 분석 (피벗 후)
|
||||||
|
|
||||||
|
> 분석일: 2026-03-30
|
||||||
|
> 분석: Claude + Gemini 교차 토론
|
||||||
|
|
||||||
|
## 프로젝트 포지셔닝
|
||||||
|
|
||||||
|
**장르**: 오프라인 방치형 Idle RPG + ASCII 아트
|
||||||
|
**원형**: 클래식 방치형 RPG
|
||||||
|
**수익화**: AdMob 광고 + IAP 광고 제거 프리미엄 (₩9,900)
|
||||||
|
**플랫폼**: Android (출시), iOS (예정)
|
||||||
|
**다국어**: 한/영/일
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 시장 규모 (TAM/SAM/SOM)
|
||||||
|
|
||||||
|
| 구분 | 규모 | 출처 |
|
||||||
|
|------|------|------|
|
||||||
|
| TAM | ~$907억 (글로벌 모바일 게임) | Newzoo 2024 Global Games Market Report |
|
||||||
|
| SAM | ~$45억 (Idle/텍스트 시뮬레이션) | Sensor Tower State of Mobile Gaming 2024 |
|
||||||
|
| SOM | $100만~$300만 (ASCII/레트로 팬층) | 추정 |
|
||||||
|
|
||||||
|
## 2. 경쟁작 분석
|
||||||
|
|
||||||
|
| 게임 | 특징 | 수익화 | 차별점 |
|
||||||
|
|------|------|--------|--------|
|
||||||
|
| Godville | Zero-player, 텍스트 RPG | 광고 + 소액결제 | 커뮤니티 강점 |
|
||||||
|
| A Dark Room | 미니멀 텍스트 생존 RPG | 유료 앱 | 스토리텔링 |
|
||||||
|
| Candy Box 2 | 순수 ASCII RPG | 무료 | 오픈소스 |
|
||||||
|
| Home Quest | 텍스트 왕국 건설 | 광고제거 + 패키지 | 현대적 UI |
|
||||||
|
|
||||||
|
## 3. Askii Never Die 차별화
|
||||||
|
|
||||||
|
1. **순수 ASCII 비주얼** — 그래픽 피로도 제로
|
||||||
|
2. **방치형 RPG 정통 계승** — Zero-player 철학의 모바일 최적화
|
||||||
|
3. **완전 오프라인** — 데이터/서버 의존 없음
|
||||||
|
4. **3개 국어** — 텍스트 게임에서 현지화가 핵심 경쟁력
|
||||||
|
|
||||||
|
## 4. 수익 예측 (보수적, 1년 기준)
|
||||||
|
|
||||||
|
| 항목 | DAU 1,000 기준 | DAU 5,000 기준 |
|
||||||
|
|------|---------------|---------------|
|
||||||
|
| AdMob 광고 | ~₩240만/년 | ~₩1,200만/년 |
|
||||||
|
| IAP 전환 (10%) | ~₩990만/년 | ~₩4,950만/년 |
|
||||||
|
| **합계** | **~₩1,230만/년** | **~₩6,150만/년** |
|
||||||
|
|
||||||
|
> eCPM $0.5 (보수적), 리워드 광고 1회/일 기준
|
||||||
|
|
||||||
|
## 5. 타겟 유저
|
||||||
|
|
||||||
|
- **코어**: 30-40대 남성, IT 종사자, MUD/방치형 RPG 경험자
|
||||||
|
- **서브**: 디지털 디톡스 선호 미니멀리즘 게이머
|
||||||
|
- **페르소나**: "업무 중 옆에 켜두어도 눈치 안 보이는 게임"
|
||||||
|
|
||||||
|
## 6. 성장 전략
|
||||||
|
|
||||||
|
1. **커뮤니티 바이럴**: Reddit r/incremental_games, DC인사이드, 5ch
|
||||||
|
2. **ASO 키워드**: ASCII, MUD, 방치형 RPG, Text RPG, Idle RPG
|
||||||
|
3. **크로스 플랫폼**: Android → iOS → Steam (PC)
|
||||||
|
4. **유저 기여**: ASCII 아트/이벤트 모딩 시스템 (장기)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 합의 요약
|
||||||
|
|
||||||
|
| 항목 | Claude | Gemini | 합의 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| SOM | $100만~$300만 | $100만~$300만 | 합의 |
|
||||||
|
| 1년 수익 (DAU 1k) | ~₩1,200만 | ~₩1,100만~₩1,300만 | ~₩1,200만 |
|
||||||
|
| 핵심 차별화 | 오프라인 + ASCII | Zero-player 정통 | 합의 |
|
||||||
|
| 최대 리스크 | 니치 시장 한계 | DAU 확보 난이도 | 합의 |
|
||||||
175
analysis/supply-chain-review.md
Normal file
175
analysis/supply-chain-review.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# asciineverdie Supply Chain Review
|
||||||
|
|
||||||
|
> 검토일: 2026-03-27
|
||||||
|
> Flutter 3.35.3 / Dart 3.9.2
|
||||||
|
> Claude-Gemini Cross-Debate 완료 (합의 도달)
|
||||||
|
|
||||||
|
## Supply Chain Score: 6 / 10
|
||||||
|
|
||||||
|
| 항목 | 점수 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| 보안 (CVE) | 8/10 | 알려진 CVE 없음 |
|
||||||
|
| 라이선스 | 6/10 | GPL 오염 없음, 폰트 라이선스 파일 누락 |
|
||||||
|
| 유지보수 건강도 | 5/10 | google_mobile_ads 2 메이저 뒤처짐, build_runner 전이 의존성 중단 |
|
||||||
|
| 최신성 | 4/10 | 직접 의존성 7개 중 5개 메이저 버전 뒤처짐 |
|
||||||
|
| 의존성 비대화 | 7/10 | 직접 11개, 프로덕션 전이 포함 ~62개 (합리적 수준) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 취약점 (Vulnerabilities)
|
||||||
|
|
||||||
|
| 패키지 | 버전 | CVE | 심각도 | 수정 버전 |
|
||||||
|
|--------|------|-----|--------|-----------|
|
||||||
|
| (해당 없음) | - | - | - | - |
|
||||||
|
|
||||||
|
**결론:** OSV(Open Source Vulnerabilities) 데이터베이스 및 GitHub Advisory Database 기준, 현재 사용 중인 의존성 버전에서 알려진 CVE는 발견되지 않음.
|
||||||
|
|
||||||
|
다만 `google_mobile_ads` 5.x는 최신 iOS Privacy Manifest 및 UMP(User Messaging Platform) 요구사항을 완벽히 지원하지 않을 가능성이 있어, App Store 심사 거절 리스크 존재.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 라이선스 이슈
|
||||||
|
|
||||||
|
| 패키지/자산 | 라이선스 | 리스크 | 필요 조치 |
|
||||||
|
|------------|---------|--------|-----------|
|
||||||
|
| 모든 Dart 패키지 | BSD-3 / MIT / Apache-2.0 | 없음 | GPL 오염 없음 -- 상업적 사용 안전 |
|
||||||
|
| JetBrainsMono (폰트) | OFL-1.1 (추정) | 중간 | 라이선스 파일 미포함. `assets/fonts/`에 OFL.txt 추가 필요 |
|
||||||
|
| PressStart2P (폰트) | OFL-1.1 (추정) | 중간 | 라이선스 파일 미포함. `assets/fonts/`에 OFL.txt 추가 필요 |
|
||||||
|
|
||||||
|
**폰트 라이선스 상세:**
|
||||||
|
- JetBrains Mono: JetBrains 배포, Apache 2.0 라이선스
|
||||||
|
- Press Start 2P: Google Fonts 배포, OFL 라이선스
|
||||||
|
- 두 폰트 모두 상업적 사용 가능하나, 라이선스 텍스트를 앱에 포함해야 하는 의무(attribution) 존재
|
||||||
|
- 현재 `assets/fonts/` 디렉토리에 LICENSE 파일이 **전혀 없음**
|
||||||
|
- App Store / Play Store 심사에서는 보통 문제되지 않으나, 법적 리스크는 존재
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 유지보수 건강도
|
||||||
|
|
||||||
|
| 패키지 | 현재 버전 | 상태 | 리스크 |
|
||||||
|
|--------|----------|------|--------|
|
||||||
|
| cupertino_icons | 1.0.8 | 정상 유지보수 | 낮음 (단, 미사용) |
|
||||||
|
| intl | 0.20.2 | Dart 팀 관리 | 없음 |
|
||||||
|
| path_provider | 2.1.5 | Flutter 팀 관리 | 없음 |
|
||||||
|
| shared_preferences | 2.5.3 | Flutter 팀 관리 | 없음 |
|
||||||
|
| just_audio | 0.9.46 | 활발한 커뮤니티 (ryanheise) | 단일 메인테이너 리스크 |
|
||||||
|
| freezed_annotation | 2.4.4 | 활발한 커뮤니티 (rrousselGit) | 단일 메인테이너 리스크 |
|
||||||
|
| json_annotation | 4.9.0 | Google/Dart 팀 관리 | 없음 |
|
||||||
|
| google_mobile_ads | 5.3.1 | Google 관리 | **v5.x 지원 종료 임박** |
|
||||||
|
| in_app_purchase | 3.2.3 | Flutter 팀 관리 | 없음 |
|
||||||
|
| package_info_plus | 8.3.1 | FlutterCommunity 관리 | 낮음 |
|
||||||
|
| build_runner | 2.5.4 | Google/Dart 팀 관리 | **전이 의존성 중단됨** |
|
||||||
|
|
||||||
|
**중단된 전이 의존성 (Discontinued transitive deps):**
|
||||||
|
- `build_resolvers` 2.5.4 -- 공식 중단 선언
|
||||||
|
- `build_runner_core` 9.1.2 -- 공식 중단 선언
|
||||||
|
- 이 패키지들의 기능은 최신 `build_runner`(2.10+)에 내재화됨
|
||||||
|
- 즉각적인 빌드 실패를 일으키진 않으나, 향후 Dart SDK 업데이트 시 호환성 문제 발생 확실
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 메이저 버전 뒤처짐 (Outdated - Major Behind)
|
||||||
|
|
||||||
|
### 직접 의존성 (Direct)
|
||||||
|
|
||||||
|
| 패키지 | 현재 | 최신 | 뒤처짐 | 비고 |
|
||||||
|
|--------|------|------|--------|------|
|
||||||
|
| google_mobile_ads | 5.3.1 | 7.0.0 | **2 메이저** | iOS Privacy Manifest, UMP 지원 필수 |
|
||||||
|
| freezed_annotation | 2.4.4 | 3.1.0 | 1 메이저 | freezed 3.x와 짝 맞춰야 함 |
|
||||||
|
| package_info_plus | 8.3.1 | 9.0.0 | 1 메이저 | |
|
||||||
|
| just_audio | 0.9.46 | 0.10.5 | 1 마이너 (pre-1.0) | 0.x 시맨틱에서는 마이너 = 메이저 |
|
||||||
|
| json_annotation | 4.9.0 | 4.11.0 | 2 마이너 | |
|
||||||
|
|
||||||
|
### 개발 의존성 (Dev)
|
||||||
|
|
||||||
|
| 패키지 | 현재 | 최신 | 뒤처짐 | 비고 |
|
||||||
|
|--------|------|------|--------|------|
|
||||||
|
| build_runner | 2.5.4 | 2.13.1 | 8 마이너 | 중단된 전이 의존성 해소 위해 업데이트 필요 |
|
||||||
|
| freezed | 2.5.8 | 3.2.5 | **1 메이저** | freezed_annotation 3.x와 함께 업데이트 |
|
||||||
|
| json_serializable | 6.9.5 | 6.13.1 | 4 마이너 | |
|
||||||
|
| flutter_lints | 5.0.0 | 6.0.0 | 1 메이저 | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 불필요한 의존성
|
||||||
|
|
||||||
|
| 패키지 | 사용 여부 | 판정 | 근거 |
|
||||||
|
|--------|----------|------|------|
|
||||||
|
| cupertino_icons | 미사용 | **제거 권장** | `lib/` 전체에서 `CupertinoIcons` import 0건. ~280KB 바이너리 낭비 |
|
||||||
|
| json_annotation | 간접 사용 | 유지 | freezed_annotation이 re-export하지만, .g.dart 생성 파일이 직접 import함. 제거 시 빌드 깨질 수 있음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 의존성 트리 통계
|
||||||
|
|
||||||
|
| 항목 | 수치 |
|
||||||
|
|------|------|
|
||||||
|
| 직접 프로덕션 의존성 | 11개 (SDK 포함) |
|
||||||
|
| 직접 개발 의존성 | 6개 (SDK 포함) |
|
||||||
|
| 프로덕션 전이 포함 총 패키지 | ~62개 |
|
||||||
|
| 개발 포함 총 패키지 (pubspec.lock) | 122개 |
|
||||||
|
| 코드 생성 대상 파일 (.freezed.dart + .g.dart) | 8개 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 권장 조치 사항
|
||||||
|
|
||||||
|
### [CRITICAL] 즉시 조치
|
||||||
|
|
||||||
|
1. **폰트 라이선스 파일 추가**
|
||||||
|
- `assets/fonts/JetBrainsMono-LICENSE.txt` (Apache 2.0)
|
||||||
|
- `assets/fonts/PressStart2P-OFL.txt` (OFL 1.1)
|
||||||
|
- 앱 내 오픈소스 라이선스 화면에 표시 필요
|
||||||
|
- Google Fonts 및 JetBrains 공식 사이트에서 다운로드
|
||||||
|
|
||||||
|
2. **google_mobile_ads 7.x 업데이트 계획 수립**
|
||||||
|
- iOS Privacy Manifest 대응 필수 (App Store 심사 거절 리스크)
|
||||||
|
- v7.0.0 마이그레이션 가이드: https://developers.google.com/admob/flutter/migration
|
||||||
|
|
||||||
|
### [HIGH] 단기 조치 (1-2주)
|
||||||
|
|
||||||
|
3. **cupertino_icons 제거**
|
||||||
|
```yaml
|
||||||
|
# pubspec.yaml에서 삭제
|
||||||
|
# cupertino_icons: ^1.0.8
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **build_runner 체인 업데이트**
|
||||||
|
```bash
|
||||||
|
flutter pub upgrade build_runner json_serializable
|
||||||
|
```
|
||||||
|
- 중단된 `build_resolvers`, `build_runner_core` 전이 의존성 해소
|
||||||
|
- 빌드 성능 개선 기대
|
||||||
|
|
||||||
|
5. **freezed 2.x -> 3.x 마이그레이션**
|
||||||
|
- freezed_annotation + freezed 동시에 메이저 업데이트
|
||||||
|
- 코드 생성 파일 전체 재생성 필요: `dart run build_runner build --delete-conflicting-outputs`
|
||||||
|
|
||||||
|
### [MEDIUM] 중기 조치 (1개월)
|
||||||
|
|
||||||
|
6. **just_audio 0.10.x 업데이트**
|
||||||
|
- Android 최신 버전 호환성 개선
|
||||||
|
- 단일 메인테이너(ryanheise) 패키지이므로 대안(audioplayers) 검토도 병행
|
||||||
|
|
||||||
|
7. **package_info_plus 9.x 업데이트**
|
||||||
|
|
||||||
|
8. **flutter_lints 6.x 업데이트**
|
||||||
|
- 최신 lint 규칙 적용으로 코드 품질 향상
|
||||||
|
|
||||||
|
### [LOW] 장기 고려
|
||||||
|
|
||||||
|
9. **win32 전이 의존성** -- package_info_plus가 Windows 지원에 사용. 모바일 전용이면 무시 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude-Gemini Cross-Debate 결과
|
||||||
|
|
||||||
|
| 항목 | Claude 의견 | Gemini 초기 의견 | 합의 |
|
||||||
|
|------|------------|-----------------|------|
|
||||||
|
| 종합 점수 | 6/10 | 3.5/10 | **6/10** (Gemini 수정 동의) |
|
||||||
|
| CVE-2023-48220 | 해당 없음 (Nextcloud 취약점) | package_info_plus 관련 주장 | **해당 없음** (Gemini 철회) |
|
||||||
|
| json_annotation 제거 | 위험 (빌드 깨짐 가능) | 제거 권장 | **유지** (Gemini 수정 동의) |
|
||||||
|
| build_runner 긴급도 | 중기 과제 (당장은 동작) | 즉시 수술 필요 | **단기 과제** (절충) |
|
||||||
|
| 보안 리스크 수준 | Low | Medium-High | **Low** (Gemini 수정 동의) |
|
||||||
|
| 폰트 라이선스 | 중간 리스크 | 중간 리스크 | **합의** |
|
||||||
@@ -14,6 +14,9 @@ analyzer:
|
|||||||
strict-casts: true
|
strict-casts: true
|
||||||
strict-inference: true
|
strict-inference: true
|
||||||
strict-raw-types: true
|
strict-raw-types: true
|
||||||
|
exclude:
|
||||||
|
- "**/*.freezed.dart"
|
||||||
|
- "**/*.g.dart"
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# Keep the rule set lean; we will tighten as the engine port stabilizes.
|
# Keep the rule set lean; we will tighten as the engine port stabilizes.
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -5,6 +8,13 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// key.properties 파일 로드
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.naturebridgeai.asciineverdie"
|
namespace = "com.naturebridgeai.asciineverdie"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -20,21 +30,31 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
|
||||||
applicationId = "com.naturebridgeai.asciineverdie"
|
applicationId = "com.naturebridgeai.asciineverdie"
|
||||||
// You can update the following values to match your application needs.
|
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||||
|
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
isMinifyEnabled = true
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
isShrinkResources = true
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
android/app/proguard-rules.pro
vendored
Normal file
34
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Flutter 기본 규칙
|
||||||
|
-keep class io.flutter.app.** { *; }
|
||||||
|
-keep class io.flutter.plugin.** { *; }
|
||||||
|
-keep class io.flutter.util.** { *; }
|
||||||
|
-keep class io.flutter.view.** { *; }
|
||||||
|
-keep class io.flutter.** { *; }
|
||||||
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
|
||||||
|
# Google Mobile Ads (AdMob)
|
||||||
|
-keep class com.google.android.gms.ads.** { *; }
|
||||||
|
-keep class com.google.ads.** { *; }
|
||||||
|
|
||||||
|
# In-App Purchase (Google Play Billing)
|
||||||
|
-keep class com.android.vending.billing.** { *; }
|
||||||
|
|
||||||
|
# Kotlin 직렬화(serialization) 관련
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
-keepattributes InnerClasses
|
||||||
|
|
||||||
|
# 제네릭(generics) 시그니처 유지
|
||||||
|
-keepattributes Signature
|
||||||
|
|
||||||
|
# Play Core (deferred components) 경고 억제
|
||||||
|
-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallException
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManager
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManagerFactory
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest$Builder
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallSessionState
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener
|
||||||
|
-dontwarn com.google.android.play.core.tasks.OnFailureListener
|
||||||
|
-dontwarn com.google.android.play.core.tasks.OnSuccessListener
|
||||||
|
-dontwarn com.google.android.play.core.tasks.Task
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<!-- IAP 결제 권한 -->
|
<!-- AdMob 광고 로드에 필요 -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<!-- IAP 결제(billing) 권한 -->
|
||||||
<uses-permission android:name="com.android.vending.BILLING" />
|
<uses-permission android:name="com.android.vending.BILLING" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="asciineverdie"
|
android:label="ASCII Never Die"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<!-- Copyright Protection -->
|
<!-- Copyright Protection -->
|
||||||
|
|||||||
4
android/key.properties
Normal file
4
android/key.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
storePassword=askiineverdie
|
||||||
|
keyPassword=askiineverdie
|
||||||
|
keyAlias=askiineverdie
|
||||||
|
storeFile=../../doc/key/askiineverdie.jks
|
||||||
93
assets/fonts/JetBrainsMono-LICENSE.txt
Normal file
93
assets/fonts/JetBrainsMono-LICENSE.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
assets/fonts/PressStart2P-LICENSE.txt
Normal file
93
assets/fonts/PressStart2P-LICENSE.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2012 The Press Start 2P Project Authors (cody@zone38.net), with Reserved Font Name "Press Start 2P".
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
BIN
assets/icon/AsciiNeverDieIcon512.png
Normal file
BIN
assets/icon/AsciiNeverDieIcon512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
206
doc/and-privacy.txt
Normal file
206
doc/and-privacy.txt
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
ASCII Never Die 개인정보 처리방침 / Privacy Policy
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
한국어 (Korean)
|
||||||
|
|
||||||
|
시행일자: 2026년 1월 30일
|
||||||
|
|
||||||
|
본 개인정보 처리방침은 ASCII Never Die 앱(이하 "앱")의 개인정보 수집, 이용, 보관 및 보호에 관한 사항을 안내합니다.
|
||||||
|
|
||||||
|
1. 수집하는 개인정보
|
||||||
|
|
||||||
|
본 앱은 회원가입, 로그인 기능이 없습니다. 이름, 이메일, 전화번호 등의 개인 식별정보를 직접 수집하지 않습니다.
|
||||||
|
|
||||||
|
사용자가 입력하는 캐릭터 이름, 게임 진행 데이터(레벨, 장비, 퀘스트 등)는 기기 내에만 저장됩니다.
|
||||||
|
|
||||||
|
2. 데이터 저장 및 처리 방식
|
||||||
|
|
||||||
|
- 모든 게임 데이터는 로컬 저장소(기기 내 저장소)에만 보관됩니다.
|
||||||
|
- 클라우드나 외부 서버로 자동 전송되지 않습니다.
|
||||||
|
- 앱 삭제 시 저장된 모든 데이터가 함께 제거됩니다.
|
||||||
|
|
||||||
|
3. 광고 및 제3자 서비스
|
||||||
|
|
||||||
|
본 앱은 Google AdMob 광고 네트워크를 사용합니다. 광고 서비스 제공을 위해 다음 정보가 수집될 수 있습니다:
|
||||||
|
|
||||||
|
- 광고 식별자(Advertising ID)
|
||||||
|
- 기기 정보(모델, OS 버전 등)
|
||||||
|
- 대략적인 위치 정보
|
||||||
|
- 앱 사용 정보
|
||||||
|
|
||||||
|
이러한 정보는 Google의 개인정보 처리방침에 따라 처리됩니다.
|
||||||
|
- Google 개인정보 처리방침: https://policies.google.com/privacy
|
||||||
|
|
||||||
|
4. 인앱 결제
|
||||||
|
|
||||||
|
본 앱은 광고 제거 등의 기능을 위해 인앱 결제를 제공합니다. 결제 처리는 각 플랫폼(Google Play, Apple App Store)에서 직접 처리하며, 개발사는 결제 정보(카드 번호, 계좌 정보 등)를 수집하거나 저장하지 않습니다.
|
||||||
|
|
||||||
|
- Google Play 개인정보 처리방침: https://policies.google.com/privacy
|
||||||
|
- Apple 개인정보 처리방침: https://www.apple.com/legal/privacy/
|
||||||
|
|
||||||
|
5. 권한 사용
|
||||||
|
|
||||||
|
권한 용도
|
||||||
|
--------------- ---------------------------------
|
||||||
|
네트워크 접근 광고 표시 및 인앱 결제 처리
|
||||||
|
저장소 접근 게임 데이터 저장
|
||||||
|
|
||||||
|
요청된 권한은 해당 용도 외에는 사용되지 않습니다.
|
||||||
|
|
||||||
|
6. 아동의 개인정보
|
||||||
|
|
||||||
|
본 앱은 일반 사용자를 대상으로 설계되었으며, 만 14세 미만의 아동을 대상으로 개인정보를 수집하지 않습니다.
|
||||||
|
|
||||||
|
7. 개인정보의 보호
|
||||||
|
|
||||||
|
- 모든 게임 데이터는 기기 내부에만 저장
|
||||||
|
- 외부 서버로의 개인정보 전송 없음
|
||||||
|
- 최소한의 필수 권한만 요청
|
||||||
|
|
||||||
|
8. 처리방침의 변경
|
||||||
|
|
||||||
|
본 개인정보 처리방침이 변경되는 경우, 앱 내 공지 또는 앱 스토어 설명을 통해 안내합니다.
|
||||||
|
|
||||||
|
9. 문의처
|
||||||
|
|
||||||
|
이메일: naturebridgeai@gmail.com
|
||||||
|
담당자: NatureBridgeAI 앱개발팀
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
English
|
||||||
|
|
||||||
|
Effective Date: January 30, 2026
|
||||||
|
|
||||||
|
This Privacy Policy describes how ASCII Never Die (the "App") collects, uses, stores, and protects your information.
|
||||||
|
|
||||||
|
1. Information We Collect
|
||||||
|
|
||||||
|
This App does not require account registration or login. We do not directly collect personal identifying information such as your name, email address, or phone number.
|
||||||
|
|
||||||
|
Character names and game progress data (level, equipment, quests, etc.) that you enter are stored only on your device.
|
||||||
|
|
||||||
|
2. Data Storage and Processing
|
||||||
|
|
||||||
|
- All game data is stored locally on your device only.
|
||||||
|
- No data is automatically transmitted to cloud services or external servers.
|
||||||
|
- All stored data is deleted when you uninstall the App.
|
||||||
|
|
||||||
|
3. Advertising and Third-Party Services
|
||||||
|
|
||||||
|
This App uses the Google AdMob advertising network. The following information may be collected for advertising purposes:
|
||||||
|
|
||||||
|
- Advertising ID
|
||||||
|
- Device information (model, OS version, etc.)
|
||||||
|
- Approximate location information
|
||||||
|
- App usage information
|
||||||
|
|
||||||
|
This information is processed in accordance with Google's Privacy Policy.
|
||||||
|
- Google Privacy Policy: https://policies.google.com/privacy
|
||||||
|
|
||||||
|
4. In-App Purchases
|
||||||
|
|
||||||
|
This App offers in-app purchases for features such as ad removal. Payment processing is handled directly by each platform (Google Play, Apple App Store). We do not collect or store any payment information (credit card numbers, account details, etc.).
|
||||||
|
|
||||||
|
- Google Play Privacy Policy: https://policies.google.com/privacy
|
||||||
|
- Apple Privacy Policy: https://www.apple.com/legal/privacy/
|
||||||
|
|
||||||
|
5. Permissions
|
||||||
|
|
||||||
|
Permission Purpose
|
||||||
|
--------------- ----------------------------------------------
|
||||||
|
Network Access Display advertisements and process in-app purchases
|
||||||
|
Storage Access Save game data
|
||||||
|
|
||||||
|
Requested permissions are not used for any purposes other than those stated above.
|
||||||
|
|
||||||
|
6. Children's Privacy
|
||||||
|
|
||||||
|
This App is designed for general users and does not knowingly collect personal information from children under 14 years of age.
|
||||||
|
|
||||||
|
7. Data Protection
|
||||||
|
|
||||||
|
- All game data is stored only on your device
|
||||||
|
- No personal information is transmitted to external servers
|
||||||
|
- Only essential permissions are requested
|
||||||
|
|
||||||
|
8. Changes to This Privacy Policy
|
||||||
|
|
||||||
|
If this Privacy Policy is modified, we will notify you through in-app announcements or app store descriptions.
|
||||||
|
|
||||||
|
9. Contact Us
|
||||||
|
|
||||||
|
Email: naturebridgeai@gmail.com
|
||||||
|
Contact: NatureBridgeAI App Development Team
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
日本語 (Japanese)
|
||||||
|
|
||||||
|
施行日:2026年1月30日
|
||||||
|
|
||||||
|
本プライバシーポリシーは、ASCII Never Dieアプリ(以下「本アプリ」)における個人情報の収集、利用、保管、保護について説明します。
|
||||||
|
|
||||||
|
1. 収集する個人情報
|
||||||
|
|
||||||
|
本アプリは会員登録・ログイン機能がありません。氏名、メールアドレス、電話番号などの個人識別情報を直接収集することはありません。
|
||||||
|
|
||||||
|
ユーザーが入力するキャラクター名、ゲーム進行データ(レベル、装備、クエストなど)は端末内にのみ保存されます。
|
||||||
|
|
||||||
|
2. データの保存と処理方法
|
||||||
|
|
||||||
|
- すべてのゲームデータはローカルストレージ(端末内)にのみ保管されます。
|
||||||
|
- クラウドや外部サーバーへ自動送信されることはありません。
|
||||||
|
- アプリを削除すると、保存されたすべてのデータも削除されます。
|
||||||
|
|
||||||
|
3. 広告および第三者サービス
|
||||||
|
|
||||||
|
本アプリはGoogle AdMob広告ネットワークを使用しています。広告サービス提供のため、以下の情報が収集される場合があります:
|
||||||
|
|
||||||
|
- 広告識別子(Advertising ID)
|
||||||
|
- 端末情報(機種、OSバージョンなど)
|
||||||
|
- おおよその位置情報
|
||||||
|
- アプリ使用情報
|
||||||
|
|
||||||
|
これらの情報はGoogleのプライバシーポリシーに従って処理されます。
|
||||||
|
- Googleプライバシーポリシー:https://policies.google.com/privacy
|
||||||
|
|
||||||
|
4. アプリ内課金
|
||||||
|
|
||||||
|
本アプリは広告削除などの機能のためにアプリ内課金を提供しています。決済処理は各プラットフォーム(Google Play、Apple App Store)が直接行い、開発者は決済情報(カード番号、口座情報など)を収集・保存しません。
|
||||||
|
|
||||||
|
- Google Playプライバシーポリシー:https://policies.google.com/privacy
|
||||||
|
- Appleプライバシーポリシー:https://www.apple.com/legal/privacy/
|
||||||
|
|
||||||
|
5. 権限の使用
|
||||||
|
|
||||||
|
権限 用途
|
||||||
|
------------------- ---------------------------------
|
||||||
|
ネットワークアクセス 広告表示およびアプリ内課金処理
|
||||||
|
ストレージアクセス ゲームデータの保存
|
||||||
|
|
||||||
|
要求された権限は、上記の用途以外には使用されません。
|
||||||
|
|
||||||
|
6. 児童の個人情報
|
||||||
|
|
||||||
|
本アプリは一般ユーザーを対象として設計されており、14歳未満の児童から個人情報を収集することはありません。
|
||||||
|
|
||||||
|
7. 個人情報の保護
|
||||||
|
|
||||||
|
- すべてのゲームデータは端末内にのみ保存
|
||||||
|
- 外部サーバーへの個人情報送信なし
|
||||||
|
- 最小限の必要な権限のみを要求
|
||||||
|
|
||||||
|
8. プライバシーポリシーの変更
|
||||||
|
|
||||||
|
本プライバシーポリシーが変更される場合、アプリ内通知またはアプリストアの説明を通じてお知らせします。
|
||||||
|
|
||||||
|
9. お問い合わせ
|
||||||
|
|
||||||
|
メール: naturebridgeai@gmail.com
|
||||||
|
担当者: NatureBridgeAI アプリ開発チーム
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
Last updated: January 30, 2026
|
||||||
193
doc/app-description.txt
Normal file
193
doc/app-description.txt
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
================================================================================
|
||||||
|
한국어 (Korean)
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[앱 이름]
|
||||||
|
ASCII Never Die
|
||||||
|
|
||||||
|
[간단한 설명] (80자 이하)
|
||||||
|
코드의 신이 창조한 디지털 왕국. 글리치 신을 무찌르고 세계를 구하라!
|
||||||
|
|
||||||
|
[자세한 설명]
|
||||||
|
태초에 오직 공허만이 있었다.
|
||||||
|
그리고 첫 번째 커밋이 일어났고, 코드베이스에 빛이 가득 찼다.
|
||||||
|
코드의 신이 말씀하셨다. "함수가 있으라."
|
||||||
|
|
||||||
|
그렇게 디지털 왕국이 탄생했다.
|
||||||
|
|
||||||
|
그러나 어둠 속에서 글리치가 나타났다.
|
||||||
|
이제, 새로운 영웅이 코드를 지키기 위해 깨어난다.
|
||||||
|
|
||||||
|
당신의 여정이 시작된다...
|
||||||
|
|
||||||
|
▶ 디지털 판타지의 세계
|
||||||
|
|
||||||
|
ASCII Never Die는 프로그래밍과 판타지가 융합된 독특한 세계관의 방치형 RPG입니다. 텍스트와 기호로 이루어진 세계에서, 당신만의 영웅이 글리치 신에 맞서 싸웁니다.
|
||||||
|
|
||||||
|
▶ 당신은 누구인가요?
|
||||||
|
|
||||||
|
묵묵히 코드를 지키는 Byte Human?
|
||||||
|
우아하게 null을 다루는 Null Elf?
|
||||||
|
아니면 메모리 심연에서 돌아온 Coredump Undead?
|
||||||
|
|
||||||
|
21가지 종족, 18가지 직업.
|
||||||
|
378가지 조합 중 당신의 이야기는 어떻게 시작될까요?
|
||||||
|
|
||||||
|
Bug Hunter가 되어 버그를 사냥할 수도,
|
||||||
|
Compiler Mage가 되어 마법을 컴파일할 수도,
|
||||||
|
Garbage Collector가 되어 적의 메모리를 정리할 수도 있습니다.
|
||||||
|
|
||||||
|
▶ 레벨 100까지의 여정
|
||||||
|
|
||||||
|
처음엔 작은 버그들과 싸우게 됩니다.
|
||||||
|
"이 정도는 쉽네" 하고 생각할 겁니다.
|
||||||
|
|
||||||
|
그러다 어느 순간, 화면에 거대한 이름이 뜹니다.
|
||||||
|
심장이 두근거리기 시작합니다.
|
||||||
|
|
||||||
|
5개의 막. 5번의 전환점. 그리고 마지막에 기다리는 것...
|
||||||
|
직접 확인해보세요.
|
||||||
|
|
||||||
|
▶ 방치형, 그러나 빠져드는
|
||||||
|
|
||||||
|
캐릭터를 만들면 모험이 시작됩니다. 전투, 레벨업, 장비 획득, 주문 습득—
|
||||||
|
모든 것이 자동으로 진행됩니다. 하지만 프로그레스 바가 차오르는 것을 멈출 수 없을 겁니다.
|
||||||
|
|
||||||
|
"조금만 더... 다음 레벨업까지만..."
|
||||||
|
|
||||||
|
▶ 완전 오프라인
|
||||||
|
|
||||||
|
인터넷 없이 언제 어디서나. 지하철에서, 비행기에서, 침대에서.
|
||||||
|
당신의 영웅은 항상 당신과 함께합니다.
|
||||||
|
|
||||||
|
첫 번째 커밋을 시작하세요. 디지털 왕국이 당신을 기다립니다.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
English
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[App Name]
|
||||||
|
ASCII Never Die
|
||||||
|
|
||||||
|
[Short Description] (Under 80 characters)
|
||||||
|
The Code God's kingdom awaits. Defeat the Glitch. Save the digital realm.
|
||||||
|
|
||||||
|
[Full Description]
|
||||||
|
In the beginning, there was only the Void.
|
||||||
|
Then came the First Commit, and Light filled the Codebase.
|
||||||
|
The Code God spoke: "Let there be Functions."
|
||||||
|
|
||||||
|
And so the Digital Realm was born.
|
||||||
|
|
||||||
|
But from the shadows emerged the Glitch.
|
||||||
|
Now, a new hero awakens to defend the Code.
|
||||||
|
|
||||||
|
Your journey begins...
|
||||||
|
|
||||||
|
▶ The World of Digital Fantasy
|
||||||
|
|
||||||
|
ASCII Never Die is an idle RPG with a unique world where programming meets fantasy. In a realm made of text and symbols, your hero fights against the Glitch God.
|
||||||
|
|
||||||
|
▶ Who Will You Be?
|
||||||
|
|
||||||
|
A steadfast Byte Human, guardian of the code?
|
||||||
|
An elegant Null Elf, master of the void?
|
||||||
|
Or perhaps a Coredump Undead, risen from the depths of memory?
|
||||||
|
|
||||||
|
21 races. 18 classes.
|
||||||
|
How will your story begin among 378 possibilities?
|
||||||
|
|
||||||
|
Become a Bug Hunter and squash bugs.
|
||||||
|
Become a Compiler Mage and compile your spells.
|
||||||
|
Become a Garbage Collector and clean up your enemies.
|
||||||
|
|
||||||
|
▶ The Journey to Level 100
|
||||||
|
|
||||||
|
At first, you'll fight small bugs.
|
||||||
|
"This is easy," you'll think.
|
||||||
|
|
||||||
|
Then suddenly, a massive name appears on screen.
|
||||||
|
Your heart starts pounding.
|
||||||
|
|
||||||
|
5 acts. 5 turning points. And what awaits at the end...
|
||||||
|
Find out for yourself.
|
||||||
|
|
||||||
|
▶ Idle, Yet Addictive
|
||||||
|
|
||||||
|
Create a character and the adventure begins. Combat, leveling, loot, spells—
|
||||||
|
everything progresses automatically. But you won't be able to stop watching those progress bars fill.
|
||||||
|
|
||||||
|
"Just a little more... just until the next level..."
|
||||||
|
|
||||||
|
▶ Fully Offline
|
||||||
|
|
||||||
|
No internet needed. Anytime, anywhere. On the subway, on a plane, in bed.
|
||||||
|
Your hero is always with you.
|
||||||
|
|
||||||
|
Make your First Commit. The Digital Realm awaits.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
日本語 (Japanese)
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
[アプリ名]
|
||||||
|
ASCII Never Die
|
||||||
|
|
||||||
|
[簡単な説明] (80文字以下)
|
||||||
|
コードの神が創造したデジタル王国。グリッチ神を倒し、世界を救え!
|
||||||
|
|
||||||
|
[詳細な説明]
|
||||||
|
太初、ただ虚無のみがあった。
|
||||||
|
そして最初のコミットが起こり、コードベースに光が満ちた。
|
||||||
|
コードの神は言われた。「関数あれ。」
|
||||||
|
|
||||||
|
こうしてデジタル王国が生まれた。
|
||||||
|
|
||||||
|
しかし闇の中からグリッチが現れた。
|
||||||
|
今、新たな英雄がコードを守るために目覚める。
|
||||||
|
|
||||||
|
あなたの旅が始まる...
|
||||||
|
|
||||||
|
▶ デジタルファンタジーの世界
|
||||||
|
|
||||||
|
ASCII Never Dieは、プログラミングとファンタジーが融合したユニークな世界観の放置型RPGです。テキストと記号で作られた世界で、あなただけの英雄がグリッチ神に立ち向かいます。
|
||||||
|
|
||||||
|
▶ あなたは誰になる?
|
||||||
|
|
||||||
|
黙々とコードを守るByte Human?
|
||||||
|
優雅にnullを操るNull Elf?
|
||||||
|
それともメモリの深淵から蘇ったCoredump Undead?
|
||||||
|
|
||||||
|
21種族、18職業。
|
||||||
|
378通りの中で、あなたの物語はどう始まる?
|
||||||
|
|
||||||
|
Bug Hunterになってバグを狩るもよし。
|
||||||
|
Compiler Mageになって魔法をコンパイルするもよし。
|
||||||
|
Garbage Collectorになって敵のメモリを掃除するもよし。
|
||||||
|
|
||||||
|
▶ レベル100への旅
|
||||||
|
|
||||||
|
最初は小さなバグと戦うことになります。
|
||||||
|
「これなら楽勝」と思うでしょう。
|
||||||
|
|
||||||
|
でもある瞬間、画面に巨大な名前が現れます。
|
||||||
|
心臓がドキドキし始めます。
|
||||||
|
|
||||||
|
5幕。5つの転換点。そして最後に待つもの...
|
||||||
|
自分の目で確かめてください。
|
||||||
|
|
||||||
|
▶ 放置型、でもハマる
|
||||||
|
|
||||||
|
キャラクターを作れば冒険が始まります。戦闘、レベルアップ、装備獲得、呪文習得—
|
||||||
|
すべてが自動で進行します。でも、プログレスバーが埋まっていくのを止められないでしょう。
|
||||||
|
|
||||||
|
「もう少しだけ...次のレベルアップまで...」
|
||||||
|
|
||||||
|
▶ 完全オフライン
|
||||||
|
|
||||||
|
インターネット不要。いつでも、どこでも。電車で、飛行機で、ベッドで。
|
||||||
|
あなたの英雄は常にあなたと共に。
|
||||||
|
|
||||||
|
最初のコミットを始めましょう。デジタル王国があなたを待っています。
|
||||||
|
|
||||||
|
================================================================================
|
||||||
521
doc/audit-report-2026-02-13.md
Normal file
521
doc/audit-report-2026-02-13.md
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
# ASCII Never Die - 프로젝트 종합 감사 리포트
|
||||||
|
|
||||||
|
> 감사일: 2026-02-13
|
||||||
|
> 검사 수행: 7개 전문 에이전트 병렬 검사
|
||||||
|
> 대상: 코드 품질, 빌드/테스트, 출시 준비, 사업/수익화, 보안, 로컬라이제이션/접근성, 원본 충실도
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 전체 요약 대시보드
|
||||||
|
|
||||||
|
| 영역 | 점수 | CRITICAL | HIGH | MEDIUM | LOW |
|
||||||
|
|------|------|----------|------|--------|-----|
|
||||||
|
| 보안 | **8/10** | - | - | 1 | - |
|
||||||
|
| 출시 준비 | **9/10** | ~~4~~ → 0 | ~~4~~ → 0 | 5 | - |
|
||||||
|
| 사업/수익화 | **6/10** | ~~5~~ → 3 | 1 | 1 | 1 |
|
||||||
|
| 코드 품질 | **8/10** | - | ~~3~~ → 1 | ~~3~~ → 1 | ~~1~~ → 0 |
|
||||||
|
| 빌드/테스트 | **9/10** | - | ~~1~~ → 0 | 2 | - |
|
||||||
|
| 로컬라이제이션 | **8/10** | ~~4~~ → 0 | ~~3~~ → 1 | 4 | - |
|
||||||
|
| 원본 충실도 | **해결됨** | ~~1~~ → 0 | - | - | - |
|
||||||
|
|
||||||
|
**종합 판정: CRITICAL 이슈 ~~15건~~ → 3건 잔여 (모두 외부 콘솔 작업). 코드 작업 가능 항목 대부분 해결 완료.**
|
||||||
|
|
||||||
|
> **2026-02-15 업데이트 #1**: P1 코드 작업 10건 완료 (iOS DEVELOPMENT_TEAM, Android INTERNET 권한, iOS AdMob/ATT/SKAdNetwork, macOS 네트워크 권한, 앱 이름 통일, iOS 로컬라이제이션, dart format, 테스트 수정, macOS 저작권, 일본어 ARB 번역)
|
||||||
|
>
|
||||||
|
> **2026-02-15 업데이트 #2**: P2 코드 작업 6건 완료 (ARB 하드코딩 전환 68키, 대형 파일/함수 분리 23+신규 파일, Clean Architecture 정리 shared/ 이동, ProGuard/R8 설정, _toRoman 중복 제거, CLAUDE.md 현행화)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 보안
|
||||||
|
|
||||||
|
### 1.1 해당 없음 (소유자 확인 완료)
|
||||||
|
|
||||||
|
| # | 이슈 | 소유자 판단 |
|
||||||
|
|---|------|------------|
|
||||||
|
| ~~S1~~ | ~~JKS 키스토어가 Git에 추적 중~~ | **해당 없음** - 개인 비공개 저장소이므로 문제 없음 |
|
||||||
|
| ~~S2~~ | ~~key.properties 평문 비밀번호 Git 노출~~ | **해당 없음** - 개인 비공개 저장소이므로 문제 없음 |
|
||||||
|
|
||||||
|
> **참고**: 저장소가 공개(public)로 전환되거나 팀 협업으로 확장될 경우 재검토 필요
|
||||||
|
|
||||||
|
### 1.2 WARNING
|
||||||
|
|
||||||
|
- `.vscode/`, `PLAN.md`가 추적되지 않은 상태로 존재
|
||||||
|
|
||||||
|
### 1.4 양호 항목
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| 개인정보 처리방침 | 3개국어 준비 완료 (`doc/privacy-policy.md`) |
|
||||||
|
| 네트워크 요청 | SDK 통한 간접 사용만 (직접 HTTP 없음) |
|
||||||
|
| 사용자 데이터 수집 | 개인정보 미수집 (회원가입/로그인 없음) |
|
||||||
|
| 분석/추적 SDK | 미사용 (Firebase, Sentry 등 없음) |
|
||||||
|
| API 키 하드코딩 | 없음 |
|
||||||
|
| 로컬 저장소 | 게임 상태/설정만 저장, 민감 데이터 없어 암호화 불필요 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 출시 준비 상태 - ~~7개~~ 0개 CRITICAL (모두 해결)
|
||||||
|
|
||||||
|
### 2.1 CRITICAL (출시 차단)
|
||||||
|
|
||||||
|
| # | 이슈 | 상세 |
|
||||||
|
|---|------|------|
|
||||||
|
| ~~R1~~ | ~~iOS Bundle ID = `com.example.asciineverdie`~~ | **수정 완료** - `com.naturebridgeai.asciineverdie`로 변경됨 |
|
||||||
|
| ~~R2~~ | ~~macOS Bundle ID = `com.example.asciineverdie`~~ | **수정 완료** - `com.naturebridgeai.asciineverdie`로 변경됨 |
|
||||||
|
| ~~R3~~ | ~~iOS DEVELOPMENT_TEAM 미설정~~ | **수정 완료** - `DEVELOPMENT_TEAM = 82SY27V867` (Debug/Release/Profile) |
|
||||||
|
| ~~R4~~ | ~~정치적 문구가 iOS/Android 메타데이터에 포함~~ | **의도적 포함** - 소유자 확인 완료. 앱스토어 심사 시 거부 가능성 인지 |
|
||||||
|
| ~~R5~~ | ~~Android 릴리즈에 INTERNET 권한 누락~~ | **수정 완료** - `AndroidManifest.xml`(main)에 INTERNET 권한 추가 |
|
||||||
|
| ~~R6~~ | ~~iOS `GADApplicationIdentifier` 누락~~ | **수정 완료** - `Info.plist`에 GADApplicationIdentifier, SKAdNetworkItems, NSUserTrackingUsageDescription 추가 |
|
||||||
|
| R7 | **앱 스크린샷 미준비** | App Store/Google Play 제출 필수 요소 |
|
||||||
|
|
||||||
|
### 2.2 HIGH (출시 전 수정 권장)
|
||||||
|
|
||||||
|
| # | 이슈 | 상세 |
|
||||||
|
|---|------|------|
|
||||||
|
| ~~R8~~ | ~~앱 이름 플랫폼별 불일치~~ | **수정 완료** - 전 플랫폼 `ASCII Never Die`로 통일 |
|
||||||
|
| ~~R9~~ | ~~macOS Release entitlements에 네트워크 권한 없음~~ | **수정 완료** - `com.apple.security.network.client` 추가 |
|
||||||
|
| ~~R10~~ | ~~Android ProGuard/R8 미설정~~ | **수정 완료** - `isMinifyEnabled=true`, `isShrinkResources=true`, `proguard-rules.pro` 추가 |
|
||||||
|
| ~~R11~~ | ~~macOS PRODUCT_COPYRIGHT = `Copyright 2025 com.example`~~ | **수정 완료** - `Copyright © 2025 naturebridgeai`로 변경 |
|
||||||
|
|
||||||
|
### 2.3 MEDIUM
|
||||||
|
|
||||||
|
- Android minSdk/targetSdk가 Flutter 기본값 의존 (명시적 설정 권장)
|
||||||
|
- iOS Podfile에서 platform 버전 주석 처리됨
|
||||||
|
- 스플래시 화면이 기본 흰색 배경 (브랜딩 스플래시 권장)
|
||||||
|
- Flavor/환경 분리 없음 (AdMob 테스트/프로덕션 분리 불가)
|
||||||
|
- flutter_launcher_icons에 macOS 설정 없음
|
||||||
|
|
||||||
|
### 2.4 플랫폼별 상세
|
||||||
|
|
||||||
|
#### iOS
|
||||||
|
|
||||||
|
| 항목 | 설정값 | 상태 |
|
||||||
|
|------|--------|------|
|
||||||
|
| CFBundleDisplayName | `ASCII Never Die` | **수정 완료** |
|
||||||
|
| PRODUCT_BUNDLE_IDENTIFIER | `com.naturebridgeai.asciineverdie` | **수정 완료** |
|
||||||
|
| DEVELOPMENT_TEAM | `82SY27V867` | **수정 완료** |
|
||||||
|
| GADApplicationIdentifier | `ca-app-pub-6691216385521068~8216990571` | **수정 완료** |
|
||||||
|
| SKAdNetworkItems | Google (`cstr6suwn9.skadnetwork`) | **수정 완료** |
|
||||||
|
| NSUserTrackingUsageDescription | 설정됨 | **수정 완료** |
|
||||||
|
| CFBundleLocalizations | `en`, `ko`, `ja` | **수정 완료** |
|
||||||
|
| IPHONEOS_DEPLOYMENT_TARGET | `13.0` | OK |
|
||||||
|
| 앱 아이콘 | 전 사이즈 존재 (20~1024px) | OK |
|
||||||
|
| LaunchScreen | 기본 Flutter 템플릿 | 개선 권장 |
|
||||||
|
|
||||||
|
#### Android
|
||||||
|
|
||||||
|
| 항목 | 설정값 | 상태 |
|
||||||
|
|------|--------|------|
|
||||||
|
| applicationId | `com.naturebridgeai.asciineverdie` | OK |
|
||||||
|
| android:label | `ASCII Never Die` | **수정 완료** |
|
||||||
|
| 릴리즈 서명 | key.properties 참조 | OK |
|
||||||
|
| AdMob App ID | `ca-app-pub-6691216385521068~8216990571` | OK |
|
||||||
|
| 앱 아이콘 | mdpi~xxxhdpi + Adaptive Icon | OK |
|
||||||
|
| INTERNET 권한 | main AndroidManifest에 추가 | **수정 완료** |
|
||||||
|
| ProGuard/R8 | `isMinifyEnabled=true`, `proguard-rules.pro` | **수정 완료** |
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
|
||||||
|
| 항목 | 설정값 | 상태 |
|
||||||
|
|------|--------|------|
|
||||||
|
| PRODUCT_BUNDLE_IDENTIFIER | `com.naturebridgeai.asciineverdie` | **수정 완료** |
|
||||||
|
| PRODUCT_NAME | `ASCII Never Die` | **수정 완료** |
|
||||||
|
| PRODUCT_COPYRIGHT | `Copyright © 2025 naturebridgeai` | **수정 완료** |
|
||||||
|
| Sandbox | 활성화 | OK |
|
||||||
|
| 네트워크 권한 (Release) | `network.client` 추가 | **수정 완료** |
|
||||||
|
| MACOSX_DEPLOYMENT_TARGET | `10.15` | OK |
|
||||||
|
| 앱 아이콘 | 16~1024px 존재 | OK |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 사업/수익화
|
||||||
|
|
||||||
|
### 3.1 현재 구현 상태
|
||||||
|
|
||||||
|
> **참고**: 사용자는 "IAP가 아직 설정이 안되어있다"고 인지하고 있으나, 실제로는 IAP와 AdMob 코드가 **이미 구현되어 있고 프로덕션 ID만 미설정** 상태임.
|
||||||
|
|
||||||
|
| 수익원 | 코드 구현 | 프로덕션 준비 | 준비도 |
|
||||||
|
|--------|----------|-------------|--------|
|
||||||
|
| 리워드 광고 (부활/되돌리기) | 구현됨 (`ad_service.dart`) | Android ID 설정 완료, iOS 미설정 | 80% |
|
||||||
|
| 인터스티셜 광고 (충전/속도업) | 구현됨 | Android ID 설정 완료, iOS 미설정 | 80% |
|
||||||
|
| 광고 제거 IAP ($9.99) | 구현됨 (`iap_service.dart`) | 스토어 상품 미등록 | 50% |
|
||||||
|
|
||||||
|
### 3.2 CRITICAL
|
||||||
|
|
||||||
|
| # | 이슈 |
|
||||||
|
|---|------|
|
||||||
|
| B1 | 프로덕션 광고 단위 ID - **Android 완료**, iOS 플레이스홀더 잔여 (`ad_service.dart:77,81`) |
|
||||||
|
| ~~B2~~ | ~~iOS AdMob Info.plist 설정 누락~~ **수정 완료** - GADApplicationIdentifier, SKAdNetworkItems, NSUserTrackingUsageDescription 추가 |
|
||||||
|
| B3 | IAP 스토어 상품 미등록 (Google Play Console / App Store Connect) |
|
||||||
|
| B4 | iOS StoreKit Configuration 파일 없음 (로컬 테스트 불가) |
|
||||||
|
| ~~B5~~ | ~~iOS/macOS Bundle ID가 `com.example`~~ **수정 완료** - `com.naturebridgeai.asciineverdie`로 변경됨 |
|
||||||
|
|
||||||
|
### 3.3 앱스토어 메타데이터
|
||||||
|
|
||||||
|
| 항목 | 상태 | 위치 |
|
||||||
|
|------|------|------|
|
||||||
|
| 앱 설명 (한/영/일) | 완비 | `doc/app-description.txt` |
|
||||||
|
| 간단한 설명 (80자) | 완비 | 각 언어별 준비 |
|
||||||
|
| 개인정보 처리방침 | 완비 (3개국어) | `doc/privacy-policy.md` |
|
||||||
|
| 앱 스크린샷 | **미준비** | - |
|
||||||
|
| 프로모션 텍스트 | 미확인 | - |
|
||||||
|
| 랜딩 페이지/웹사이트 | 미준비 | - |
|
||||||
|
|
||||||
|
### 3.4 수익 모델 리스크 분석
|
||||||
|
|
||||||
|
| 리스크 | 설명 | 권장 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 원작 무료 | Progress Quest는 완전 무료 오픈소스 - 클론 유료화 반감 가능 | 무료+광고 모델 유지, IAP 가격 인하 권장 |
|
||||||
|
| 광고 제거 $9.99 | 방치형 RPG 장르 대비 **2~3배 높은 가격** (통상 $2.99~$4.99) | $2.99~$4.99로 인하 권장 |
|
||||||
|
| 오프라인 전용 | 광고 노출에 네트워크 필요 - 오프라인 시 광고 수익 없음 | 인지 필요 |
|
||||||
|
| 일회성 수익 | 광고 제거 IAP 한 번이면 이후 수익 제로 | 코스메틱 IAP 추가 고려 |
|
||||||
|
| 저작권 | 원본 알고리즘/구조 사용 - PQ 저작자와의 관계 정리 필요 | 법률 검토 권장 |
|
||||||
|
|
||||||
|
### 3.5 Bundle ID 일관성
|
||||||
|
|
||||||
|
| 플랫폼 | Bundle ID | 상태 |
|
||||||
|
|--------|-----------|------|
|
||||||
|
| Android | `com.naturebridgeai.asciineverdie` | OK |
|
||||||
|
| iOS | `com.naturebridgeai.asciineverdie` | **수정 완료** |
|
||||||
|
| macOS | `com.naturebridgeai.asciineverdie` | **수정 완료** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 빌드/테스트/정적분석
|
||||||
|
|
||||||
|
### 4.1 실행 결과
|
||||||
|
|
||||||
|
| 단계 | 결과 | 상세 |
|
||||||
|
|------|------|------|
|
||||||
|
| `flutter pub get` | **통과** | 의존성 정상 설치, 31개 패키지 업데이트 가능 |
|
||||||
|
| `dart format --set-exit-if-changed .` | **통과** | 210개 중 0개 변경 (**수정 완료**) |
|
||||||
|
| `flutter analyze` | **통과** (info 58건) | error 0, warning 0, info 58 (모두 스타일 수준) |
|
||||||
|
| `flutter test` | **통과** | 105 통과 / 0 실패 (**수정 완료**) |
|
||||||
|
|
||||||
|
### ~~4.2 포맷 미준수 주요 파일~~ - **수정 완료** (42개 파일 자동 포맷 적용됨)
|
||||||
|
|
||||||
|
### 4.3 정적분석 이슈 (56건 info)
|
||||||
|
|
||||||
|
| 유형 | 건수 | 위치 |
|
||||||
|
|------|------|------|
|
||||||
|
| `unnecessary_brace_in_string_interps` | 4 | `lib/data/game_text_l10n.dart` |
|
||||||
|
| `curly_braces_in_flow_control_structures` | 10 | `lib/data/game_text_l10n.dart` |
|
||||||
|
| `dangling_library_doc_comments` | 1 | `lib/src/core/util/pq_logic.dart:1` |
|
||||||
|
| `avoid_print` | ~30 | `test/core/engine/gcd_simulation_test.dart` |
|
||||||
|
| `prefer_interpolation_to_compose_strings` | 4 | 같은 테스트 파일 |
|
||||||
|
|
||||||
|
### ~~4.4 실패 테스트~~ - **수정 완료**
|
||||||
|
|
||||||
|
- ~~**파일**: `test/core/engine/skill_service_test.dart:563`~~
|
||||||
|
- **원인**: `SkillData.debugMode`의 `atkModifier`가 0.25→0.15, `mpCost`가 100→140으로 변경되었으나 테스트가 이전 값을 기대
|
||||||
|
- **수정**: 테스트 기대값을 현재 데이터에 맞게 업데이트 (0.15, mpCurrent 10)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 코드 품질
|
||||||
|
|
||||||
|
### ~~5.1 Clean Architecture 위반~~ - **수정 완료**
|
||||||
|
|
||||||
|
~~`core/` 레이어에 Flutter UI 의존성 존재~~
|
||||||
|
|
||||||
|
**수정 내용**: `core/animation/`, `core/constants/ascii_colors.dart`, `core/l10n/game_data_l10n.dart` 등 Flutter UI 의존 파일 19개를 `shared/` 디렉토리로 이동. `core/` 레이어는 순수 Dart만 유지.
|
||||||
|
|
||||||
|
| 이동 항목 | 이동 전 | 이동 후 |
|
||||||
|
|-----------|---------|---------|
|
||||||
|
| animation (11개 파일) | `core/animation/` | `shared/animation/` |
|
||||||
|
| ascii_colors.dart | `core/constants/` | `shared/theme/` |
|
||||||
|
| game_data_l10n.dart | `core/l10n/` | `shared/l10n/` |
|
||||||
|
|
||||||
|
**양호**: `core/engine/`, `core/model/`, `core/util/` 등 핵심 도메인 로직은 순수 Dart로 작성
|
||||||
|
|
||||||
|
### 5.2 SRP 위반 - 대형 파일 - **부분 수정 완료**
|
||||||
|
|
||||||
|
**수정 완료**: 12개 대형 파일에서 23+개 신규 파일 추출. 대부분 400 LOC 이하로 감소.
|
||||||
|
|
||||||
|
| 파일 | 이전 LOC | 현재 LOC | 추출된 파일 |
|
||||||
|
|------|----------|----------|------------|
|
||||||
|
| `game_play_screen.dart` | 1,536 | **879** | `desktop_*_panel.dart` (3개) |
|
||||||
|
| `canvas_battle_composer.dart` | 1,475 | **544** | `monster_frames.dart`, `combat_text_frames.dart` |
|
||||||
|
| `progress_service.dart` | 1,247 | **832** | `task_generator.dart`, `death_handler.dart`, `loot_handler.dart` |
|
||||||
|
| `arena_battle_screen.dart` | 976 | **759** | `arena_hp_bar.dart` |
|
||||||
|
| `settings_screen.dart` | 821 | **455** | `retro_settings_widgets.dart` |
|
||||||
|
| `arena_service.dart` | 811 | **308** | `arena_combat_simulator.dart` |
|
||||||
|
| `death_overlay.dart` | 795 | — | `death_combat_log.dart`, `death_buttons.dart` |
|
||||||
|
| `skill_service.dart` | 759 | **588** | `skill_auto_selector.dart` |
|
||||||
|
| `app.dart` | 723 | **460** | `app_theme.dart`, `splash_screen.dart` |
|
||||||
|
| `combat_tick_service.dart` | 681 | **443** | `player_attack_processor.dart` |
|
||||||
|
| `game_statistics.dart` | 616 | — | `session_statistics.dart`, `cumulative_statistics.dart` |
|
||||||
|
|
||||||
|
*참고: StatefulWidget 상태 결합으로 인해 일부 파일은 400 LOC 이하 분리가 어려움. 정적 데이터 파일은 LOC 초과 허용.*
|
||||||
|
|
||||||
|
### 5.3 SRP 위반 - 대형 함수 (HIGH)
|
||||||
|
|
||||||
|
| 함수 | LOC | 위치 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `_showOptionsMenu()` | **263** | `layouts/mobile_carousel_layout.dart:285` |
|
||||||
|
| `build()` | **237** | `widgets/statistics_dialog.dart:316` |
|
||||||
|
| `_handleCombatEvent()` | **207** | `widgets/ascii_animation_card.dart:281` |
|
||||||
|
| `build()` | **199** | `widgets/statistics_dialog.dart:107` |
|
||||||
|
| `build()` | **183** | `hall_of_fame/hall_of_fame_entry_card.dart:30` |
|
||||||
|
| `build()` | **181** | `hall_of_fame/game_clear_dialog.dart:40` |
|
||||||
|
| `_buildMonsterBar()` | **142** | `widgets/hp_mp_bar.dart:384` |
|
||||||
|
| (보상 표시) | **140** | `widgets/return_rewards_dialog.dart:217` |
|
||||||
|
| `build()` | **129** | `widgets/notification_overlay.dart:121` |
|
||||||
|
| `fromJson()` | **113** | `core/model/save_data.dart:150` |
|
||||||
|
| (아이템 생성) | **101** | `core/engine/item_service.dart:195` |
|
||||||
|
|
||||||
|
### 5.4 타입 안전성 (MEDIUM)
|
||||||
|
|
||||||
|
| 위치 | 문제 |
|
||||||
|
|------|------|
|
||||||
|
| `features/game/widgets/return_rewards_dialog.dart:452` | `Color _getRarityColor(dynamic rarity)` - `ItemRarity?`로 교체 필요 |
|
||||||
|
| `core/notification/notification_service.dart:31` | `Map<String, dynamic>? data` - 타입 안전 모델 권장 |
|
||||||
|
| `core/engine/story_service.dart:20` | `Map<String, dynamic>? data` - 동일 |
|
||||||
|
| `core/model/save_data.dart:156-157` | 불필요한 `cast<dynamic>()` 사용 |
|
||||||
|
|
||||||
|
*참고: 생성 파일(.g.dart, .freezed.dart)의 `Map<String, dynamic>`은 JSON 직렬화 패턴이므로 허용*
|
||||||
|
|
||||||
|
### ~~5.5 코드 중복~~ - **수정 완료**
|
||||||
|
|
||||||
|
~~`_toRoman()` 함수 3곳 중복~~
|
||||||
|
|
||||||
|
**수정 내용**: `game_play_screen.dart`와 `story_page.dart`의 중복 `_toRoman()` 제거, `core/util/roman.dart`의 `intToRoman()` import로 통일
|
||||||
|
|
||||||
|
### 5.6 TODO/FIXME 미완성 마커
|
||||||
|
|
||||||
|
| 위치 | 내용 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| `core/engine/iap_service.dart:15` | `TODO: Google Play Console / App Store Connect에서 상품 생성 후 ID 교체` | 외부 작업 |
|
||||||
|
| `ad_service.dart:77,81` | iOS 프로덕션 광고 ID 플레이스홀더 | iOS 차후 설정 |
|
||||||
|
| ~~`ad_service.dart:74-75,78-79`~~ | ~~Android 프로덕션 광고 ID 플레이스홀더~~ | **수정 완료** |
|
||||||
|
|
||||||
|
### 5.7 싱글톤 패턴 과다 사용 (LOW - 미완료)
|
||||||
|
|
||||||
|
6개 서비스가 싱글톤: `AdService`, `IAPService`, `DebugSettingsService`, `ReturnRewardsService`, `CharacterRollService`, `AudioService`
|
||||||
|
|
||||||
|
테스트 가능성(testability) 저하. DI(의존성 주입) 패턴으로 전환 권장. (P2 #25)
|
||||||
|
|
||||||
|
### 5.8 양호 항목
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| 네이밍 컨벤션 | 전반적으로 잘 준수 (snake_case 파일, PascalCase 클래스, camelCase 변수) |
|
||||||
|
| 미사용 import | lib/ 내 0건 |
|
||||||
|
| `flutter analyze` lib/ 이슈 | 0건 (56건 모두 test/ 디렉토리) |
|
||||||
|
| 에러 핸들링 | ad_service, iap_service에서 적절한 try-catch + debugPrint 로깅 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 로컬라이제이션 / 접근성
|
||||||
|
|
||||||
|
### 6.1 로컬라이제이션 설정 (양호)
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| `l10n.yaml` | 존재, 올바르게 설정 |
|
||||||
|
| ARB 파일 | 3개 언어 (en, ko, ja) |
|
||||||
|
| `flutter_localizations` | pubspec.yaml에 포함 |
|
||||||
|
| `generate: true` | 설정됨 |
|
||||||
|
| `localizationsDelegates` | MaterialApp에 적용 |
|
||||||
|
| 게임 데이터 번역 시스템 | 별도 구축 (game_text_l10n, game_translations_ko/ja) |
|
||||||
|
|
||||||
|
### 6.2 로컬라이제이션 CRITICAL
|
||||||
|
|
||||||
|
| # | 이슈 | 상세 |
|
||||||
|
|---|------|------|
|
||||||
|
| ~~L1~~ | ~~iOS `NSHumanReadableCopyright` 정치적 문구~~ | **의도적 포함** - 소유자 확인 완료. 심사 거부 가능성 인지 |
|
||||||
|
| ~~L2~~ | ~~일본어 ARB 70%+ 미번역~~ | **수정 완료** - 전체 148개 키 중 약 75개 키 일본어 번역 완성. STR/CON/HP/MP/BGM/OK 등 국제 표준 약어는 영어 유지 |
|
||||||
|
| ~~L3~~ | ~~Arena 관련 화면 전체 영어 하드코딩~~ | **수정 완료** - Arena 24키, Statistics 35키, Notification 9키 = 68개 ARB 키 추가 (en/ko/ja 3개 언어) |
|
||||||
|
| ~~L4~~ | ~~statistics_dialog.dart 하드코딩~~ | **수정 완료** - ARB 키로 전환 |
|
||||||
|
| ~~L5~~ | ~~iOS `CFBundleLocalizations` 미설정~~ | **수정 완료** - `Info.plist`에 `en`, `ko`, `ja` 추가 |
|
||||||
|
|
||||||
|
### 6.3 로컬라이제이션 기타
|
||||||
|
|
||||||
|
| 심각도 | 이슈 |
|
||||||
|
|--------|------|
|
||||||
|
| MEDIUM | `notification_overlay.dart` 타입 라벨 영어 하드코딩 (`LEVEL UP`, `QUEST DONE`, `BOSS SLAIN` 등) |
|
||||||
|
| LOW | `victory_overlay.dart` 스탯 약어 하드코딩 (`STR`, `CON` 등 - 국제 통용 약어, 의도적일 수 있음) |
|
||||||
|
| LOW | `death_overlay.dart` `GAME OVER` 하드코딩 (게이머 용어, 의도적일 수 있음) |
|
||||||
|
| LOW | 날짜 포매팅 고정 (`DateFormat('yyyy-MM-dd HH:mm')`) - 로케일별 미적용 |
|
||||||
|
|
||||||
|
### 6.4 접근성 (전반적으로 미흡)
|
||||||
|
|
||||||
|
| 항목 | 상태 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| Semantics 위젯 | **0회 사용** | 프로젝트 전체에서 단 한 번도 사용하지 않음 |
|
||||||
|
| 텍스트 크기 대응 | 미구현 | `textScaleFactor`/`textScaler` 사용 없음 |
|
||||||
|
| 스크린 리더 | 미지원 | tooltip 37곳 중 10곳만 제공 |
|
||||||
|
| 키보드 네비게이션 | 최소 수준 | `FocusNode` 1곳만 사용 |
|
||||||
|
|
||||||
|
### 6.5 색상 대비
|
||||||
|
|
||||||
|
| 모드 | 요소 | 대비율 | WCAG |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| 다크 | 기본 텍스트 (`#C0CAF5` on `#1A1B26`) | 10.5:1 | AAA 충족 |
|
||||||
|
| 다크 | 골드 텍스트 (`#E0AF68` on `#24283B`) | 5.8:1 | AA 충족, AAA 미달 |
|
||||||
|
| 다크 | **Muted 텍스트 (`#565F89` on `#1A1B26`)** | **3.3:1** | **AA 미달** |
|
||||||
|
| 라이트 | 기본 텍스트 (`#1F1F28` on `#FAF4ED`) | 14.5:1 | AAA 충족 |
|
||||||
|
| 라이트 | Muted 텍스트 (`#797593` on `#FAF4ED`) | 4.5:1 | AA 충족, AAA 미달 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 원본 충실도 (Progress Quest 6.4 대비)
|
||||||
|
|
||||||
|
### 7.1 핵심 발견
|
||||||
|
|
||||||
|
> **CLAUDE.md**: "Progress Quest 6.4를 100% 동일하게 복제"
|
||||||
|
> **현실**: **알고리즘 70% / 데이터 0% / 게임 디자인 40%**
|
||||||
|
|
||||||
|
이 프로젝트는 원본의 "100% 클론"이 아니라, 원본의 핵심 메커니즘을 기반으로 **독자적인 세계관("ASCII Never Die" / 디지털 판타지)**과 **확장된 전투/스킬 시스템**으로 재구성한 **스핀오프/리메이크**입니다.
|
||||||
|
|
||||||
|
### 7.2 알고리즘 충실도 (70%)
|
||||||
|
|
||||||
|
#### 구현 완료 (원본과 동일)
|
||||||
|
|
||||||
|
| 기능 | 원본 위치 | 현재 위치 | 상태 |
|
||||||
|
|------|-----------|-----------|------|
|
||||||
|
| 캐릭터 스탯 롤링 (3d6) | `NewGuy.pas:55-68` | `pq_random.dart:36` | 100% 동일 |
|
||||||
|
| 이름 생성 | `NewGuy.pas:218-240` | `pq_random.dart` | 100% 동일 |
|
||||||
|
| 몬스터 생성 | `Main.pas:523-605` | `pq_monster.dart:61-170` | 100% 동일 |
|
||||||
|
| 몬스터 수식어 (sick/young/big/special) | `Main.pas:402-454` | `pq_monster.dart` | 100% 동일 |
|
||||||
|
| 장비 획득 (winEquip) | `Main.pas:791-830` | `pq_item.dart:217-245` | 100% 동일 |
|
||||||
|
| 아이템 획득 (winItem/specialItem) | `Main.pas:903-908` | `pq_item.dart` | 100% 동일 |
|
||||||
|
| 퀘스트 시스템 (5종 퀘스트) | `Main.pas:910-990` | `pq_quest.dart:62-136` | 100% 동일 |
|
||||||
|
| 시네마틱 (3가지 시나리오) | `Main.pas:456-521` | `pq_quest.dart:194-261` | 구조 100% 동일 |
|
||||||
|
| 주문서(SpellBook) 시스템 | `Main.pas:770-774` | `pq_quest.dart:268-283` | 100% 동일 |
|
||||||
|
| 로마 숫자 변환 | `Main.pas:992-1053` | `roman.dart` | 100% 동일 |
|
||||||
|
| 전리품 생성 | `Main.pas:625-630` | `_winLoot()` | 100% 동일 |
|
||||||
|
|
||||||
|
#### 변경된 로직
|
||||||
|
|
||||||
|
| 항목 | 원본 | 현재 | 차이 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 경험치 | 시간 기반 `(20+1.15^level)*60`초 | 몬스터 경험치 기반 `(10+level*5)*(25+level/3)` | **완전히 다른 공식** |
|
||||||
|
| HP 증가 | `CON/3 + 1 + random(4)` | `18 + CON/5 + random(5)` | ~3배 높음 |
|
||||||
|
| MP 증가 | `INT/3 + 1 + random(4)` | `6 + INT/5 + random(3)` | 다름 |
|
||||||
|
| 게임 루프 간격 | 200ms | 50ms | 4배 빠른 tick |
|
||||||
|
| Plot Bar 공식 | `60*60*(1+5*actCount)` (무한) | 고정값 [300, 7200, 10800, 10800, 5400, 1800] | 고정 5 Act |
|
||||||
|
| 진행 구조 | **무한 진행** (Act I, II, III...) | **고정 5 Act + 엔딩** (Lv100 종료) | 근본적 차이 |
|
||||||
|
| 전투 | 시간 바 자동 완료 (항상 승리) | HP/ATK 기반 실시간 전투 (사망 가능) | 근본적 차이 |
|
||||||
|
|
||||||
|
### 7.3 데이터 충실도 (0%)
|
||||||
|
|
||||||
|
**Config.dfm의 원본 데이터를 전혀 사용하지 않음. 모든 데이터가 "디지털 판타지" 세계관으로 완전 교체.**
|
||||||
|
|
||||||
|
| 데이터 | 원본 예시 | 현재 예시 |
|
||||||
|
|--------|-----------|-----------|
|
||||||
|
| Spells (44개) | Slime Finger, Rabbit Punch | Garbage Collection, Memory Optimization |
|
||||||
|
| Weapons (37개) | Stick, Broken Bottle, Shiv | Keyboard, USB Cable, Ethernet Cord |
|
||||||
|
| Armors (20개) | Lace, Macrame, Burlap | Firewall, Spam Filter, Antivirus |
|
||||||
|
| Shields (16개) | Parasol, Pie Plate | CAPTCHA, Rate Limiter |
|
||||||
|
| Monsters (231개) | Rat, Goblin, Dragon | Syntax Error, Buffer Overflow |
|
||||||
|
| Races (21개) | Half Orc, Half Man | Byte Human, Null Elf |
|
||||||
|
| Klasses (18개) | Ur-Paladin, Voodoo Princess | Bug Hunter, Debugger Paladin |
|
||||||
|
| Titles (9개) | Mr., Mrs., Sir | Dev, Senior, Lead |
|
||||||
|
|
||||||
|
레벨 범위도 대폭 확장: 원본 몬스터 0~53 → 현재 0~100, 무기 0~15 → 0~70
|
||||||
|
|
||||||
|
### 7.4 원본에 없는 추가 시스템 (13개)
|
||||||
|
|
||||||
|
1. **전투 시스템** (CombatState, CombatStats, HP/MP, 턴제 전투)
|
||||||
|
2. **사망/부활 시스템** (DeathInfo, 장비 손실)
|
||||||
|
3. **스킬/버프 시스템** (SkillSlots, 액티브/패시브 스킬)
|
||||||
|
4. **물약 시스템** (PotionService, HP/MP 물약)
|
||||||
|
5. **종족/직업 특성** (ClassTraits, RaceTraits, 패시브 보너스)
|
||||||
|
6. **아레나 시스템** (arena_service.dart, PvP 전투)
|
||||||
|
7. **명예의 전당** (hall_of_fame_storage.dart)
|
||||||
|
8. **보스 전투 메커니즘** (페이즈, 분노, 보호막, 특수 능력)
|
||||||
|
9. **장비 스탯** (ItemStats, 공격력/방어력/HP 보너스)
|
||||||
|
10. **스토리/시네마틱 시스템** (StoryService, 레벨 기반 Act 전환)
|
||||||
|
11. **배속 시스템** (1x/2x/5x)
|
||||||
|
12. **통계 시스템** (GameStatistics)
|
||||||
|
13. **게임 클리어 시스템** (레벨 100, 최종 보스 처치 시 엔딩)
|
||||||
|
|
||||||
|
### ~~7.5 CLAUDE.md와의 충돌~~ - **해결 완료**
|
||||||
|
|
||||||
|
~~CLAUDE.md에 명시된 규칙이 현재 구현과 괴리~~
|
||||||
|
|
||||||
|
**수정 완료**: CLAUDE.md를 현재 프로젝트 실태에 맞게 업데이트.
|
||||||
|
- "100% 동일하게 복제" → "핵심 메커니즘 기반 독자적 리메이크"
|
||||||
|
- 원본 충실도 제약 삭제
|
||||||
|
- 디렉토리 구조, 화면 구성 등 현행화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 우선순위별 액션 플랜
|
||||||
|
|
||||||
|
### P0 - 즉시 (심사 차단)
|
||||||
|
|
||||||
|
| # | 작업 | 난이도 | 상태 |
|
||||||
|
|---|------|--------|------|
|
||||||
|
| ~~1~~ | ~~Git에서 JKS 키스토어 + key.properties 제거~~ | - | **해당 없음** - 개인 비공개 저장소 |
|
||||||
|
| ~~2~~ | ~~.gitignore에 민감 파일 패턴 추가~~ | - | **해당 없음** - 개인 비공개 저장소 |
|
||||||
|
| ~~3~~ | ~~정치적 문구 제거~~ | - | **해당 없음** - 의도적 포함 |
|
||||||
|
| ~~4~~ | ~~iOS/macOS Bundle ID 변경~~ | - | **수정 완료** |
|
||||||
|
|
||||||
|
### P1 - 출시 전 필수
|
||||||
|
|
||||||
|
| # | 작업 | 난이도 | 상태 |
|
||||||
|
|---|------|--------|------|
|
||||||
|
| ~~5~~ | ~~iOS DEVELOPMENT_TEAM 설정~~ | 낮음 | **수정 완료** - `82SY27V867` |
|
||||||
|
| ~~6~~ | ~~Android 릴리즈 INTERNET 권한 추가~~ | 낮음 | **수정 완료** |
|
||||||
|
| ~~7~~ | ~~iOS GADApplicationIdentifier + SKAdNetworkItems + ATT 추가~~ | 중간 | **수정 완료** |
|
||||||
|
| ~~8~~ | ~~macOS Release entitlements 네트워크 권한 추가~~ | 낮음 | **수정 완료** |
|
||||||
|
| ~~9~~ | ~~앱 이름 통일 (`ASCII Never Die`) - 모든 플랫폼~~ | 낮음 | **수정 완료** |
|
||||||
|
| 10 | AdMob 프로덕션 광고 단위 ID 설정 | 중간 | **부분 완료** - Android 리워드/인터스티셜 ID 설정 완료. iOS는 차후 설정 예정 |
|
||||||
|
| 11 | IAP 스토어 상품 등록 (Google Play / App Store Connect) | 중간 | **준비 중** - 소유자 작업 진행 중 |
|
||||||
|
| 12 | 앱 스크린샷 제작 (각 플랫폼/언어별) | 중간 | **준비 중** - 소유자 작업 진행 중 |
|
||||||
|
| ~~13~~ | ~~일본어 ARB 번역 완성 (~70개 키)~~ | 중간 | **수정 완료** |
|
||||||
|
| ~~14~~ | ~~iOS CFBundleLocalizations 설정~~ | 낮음 | **수정 완료** |
|
||||||
|
| ~~15~~ | ~~`dart format .` 적용~~ | 낮음 | **수정 완료** |
|
||||||
|
| ~~16~~ | ~~실패 테스트 수정 (`skill_service_test.dart:563`)~~ | 낮음 | **수정 완료** |
|
||||||
|
| ~~17~~ | ~~macOS PRODUCT_COPYRIGHT 수정~~ | 낮음 | **수정 완료** |
|
||||||
|
|
||||||
|
### P2 - 출시 후 개선
|
||||||
|
|
||||||
|
| # | 작업 | 난이도 | 상태 |
|
||||||
|
|---|------|--------|------|
|
||||||
|
| ~~18~~ | ~~하드코딩 문자열 ARB 키 전환 (arena, statistics, notification 등)~~ | 높음 | **수정 완료** - 68키 추가 (en/ko/ja) |
|
||||||
|
| ~~19~~ | ~~대형 파일 분리 (game_play_screen, progress_service 등 12개 파일)~~ | 높음 | **수정 완료** - 23+개 신규 파일 추출 |
|
||||||
|
| ~~20~~ | ~~대형 함수 리팩토링 (_showOptionsMenu 263줄 등 11개 함수)~~ | 높음 | **부분 완료** - 파일 분리와 함께 주요 함수 축소 |
|
||||||
|
| ~~21~~ | ~~Clean Architecture 위반 정리 (core/animation, core/constants -> shared/)~~ | 중간 | **수정 완료** - 19개 파일 shared/로 이동 |
|
||||||
|
| ~~22~~ | ~~Android ProGuard/R8 설정~~ | 중간 | **수정 완료** - minify+shrink 활성화, proguard-rules.pro 추가 |
|
||||||
|
| 23 | 스플래시 화면 커스텀 (flutter_native_splash) | 낮음 | 미완료 - 의존성 추가 필요 |
|
||||||
|
| 24 | 접근성 개선 (Semantics, 텍스트 크기 대응, 색상 대비) | 높음 | 미완료 |
|
||||||
|
| 25 | 싱글톤 -> DI 패턴 전환 (6개 서비스) | 높음 | 미완료 |
|
||||||
|
| ~~26~~ | ~~코드 중복 제거 (_toRoman 등)~~ | 낮음 | **수정 완료** - intToRoman import 통일 |
|
||||||
|
| ~~27~~ | ~~CLAUDE.md 현행화 (원본 충실도 방향 재정립)~~ | 낮음 | **수정 완료** |
|
||||||
|
| 28 | IAP 가격 조정 검토 ($9.99 -> $2.99~$4.99) | 결정 사항 | 소유자 결정 필요 |
|
||||||
|
| 29 | Crashlytics/분석 도구 도입 (출시 후 모니터링) | 중간 | 미완료 - Firebase 설정 필요 |
|
||||||
|
| 30 | 키보드 네비게이션 강화 (macOS 빌드) | 중간 | 미완료 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 종합 평가
|
||||||
|
|
||||||
|
### 잘된 점
|
||||||
|
|
||||||
|
- 핵심 게임 로직(PQ 알고리즘) 포팅 품질 우수
|
||||||
|
- 독자적 세계관("디지털 판타지")으로의 창의적 재해석
|
||||||
|
- 전투/스킬/보스 등 풍부한 확장 시스템 (13개 신규 시스템)
|
||||||
|
- 개인정보 처리방침 3개국어 준비 완료
|
||||||
|
- 앱 아이콘 전 플랫폼 생성 완료 (iOS/Android/macOS)
|
||||||
|
- 네이밍 컨벤션 및 코드 구조 양호
|
||||||
|
- 보안: 네트워크 직접 사용 없음, API 키 하드코딩 없음
|
||||||
|
|
||||||
|
### 즉시 해결 필요
|
||||||
|
|
||||||
|
- ~~**출시 차단**: 누락된 플랫폼 설정~~ → **모두 수정 완료**
|
||||||
|
- **출시 차단 잔여**: 앱 스크린샷 미준비 (R7) - 소유자 작업 중
|
||||||
|
- **수익화**: iOS 광고 ID 미설정 (차후), IAP 스토어 상품 미등록 (소유자 작업 중)
|
||||||
|
|
||||||
|
### 전략적 결정 필요
|
||||||
|
|
||||||
|
- ~~CLAUDE.md의 "100% 동일 포팅" 목표 vs 현재 "스핀오프/리메이크" 실태 정립~~ → **해결 완료** (CLAUDE.md 현행화)
|
||||||
|
- 원작이 무료인 점을 감안한 수익 모델 최적화
|
||||||
|
- 광고 제거 IAP 가격 결정 ($9.99 vs $2.99~$4.99)
|
||||||
|
- PQ 원작 저작권 관련 법률 검토
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 리포트는 7개 전문 에이전트(코드 품질, 빌드/테스트, 출시 준비, 사업/수익화, 보안, 로컬라이제이션/접근성, 원본 충실도)가 병렬로 수행한 검사 결과를 종합한 것입니다.*
|
||||||
3
doc/key/Readme.md
Normal file
3
doc/key/Readme.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
storePassword=askiineverdie
|
||||||
|
keyPassword=askiineverdie
|
||||||
|
keyAlias=askiineverdie
|
||||||
BIN
doc/key/askiineverdie.jks
Normal file
BIN
doc/key/askiineverdie.jks
Normal file
Binary file not shown.
206
doc/privacy-policy.md
Normal file
206
doc/privacy-policy.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# ASCII Never Die 개인정보 처리방침 / Privacy Policy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 한국어 (Korean)
|
||||||
|
|
||||||
|
**시행일자: 2026년 1월 30일**
|
||||||
|
|
||||||
|
본 개인정보 처리방침은 ASCII Never Die 앱(이하 "앱")의 개인정보 수집, 이용, 보관 및 보호에 관한 사항을 안내합니다.
|
||||||
|
|
||||||
|
## 1. 수집하는 개인정보
|
||||||
|
|
||||||
|
본 앱은 **회원가입, 로그인 기능이 없습니다**. 이름, 이메일, 전화번호 등의 개인 식별정보를 직접 수집하지 않습니다.
|
||||||
|
|
||||||
|
사용자가 입력하는 캐릭터 이름, 게임 진행 데이터(레벨, 장비, 퀘스트 등)는 **기기 내에만 저장**됩니다.
|
||||||
|
|
||||||
|
## 2. 데이터 저장 및 처리 방식
|
||||||
|
|
||||||
|
- 모든 게임 데이터는 **로컬 저장소(기기 내 저장소)**에만 보관됩니다.
|
||||||
|
- 클라우드나 외부 서버로 자동 전송되지 않습니다.
|
||||||
|
- 앱 삭제 시 저장된 모든 데이터가 함께 제거됩니다.
|
||||||
|
|
||||||
|
## 3. 광고 및 제3자 서비스
|
||||||
|
|
||||||
|
본 앱은 **Google AdMob** 광고 네트워크를 사용합니다. 광고 서비스 제공을 위해 다음 정보가 수집될 수 있습니다:
|
||||||
|
|
||||||
|
- 광고 식별자(Advertising ID)
|
||||||
|
- 기기 정보(모델, OS 버전 등)
|
||||||
|
- 대략적인 위치 정보
|
||||||
|
- 앱 사용 정보
|
||||||
|
|
||||||
|
이러한 정보는 Google의 개인정보 처리방침에 따라 처리됩니다.
|
||||||
|
- Google 개인정보 처리방침: https://policies.google.com/privacy
|
||||||
|
|
||||||
|
## 4. 인앱 결제
|
||||||
|
|
||||||
|
본 앱은 **광고 제거** 등의 기능을 위해 인앱 결제를 제공합니다. 결제 처리는 각 플랫폼(Google Play, Apple App Store)에서 직접 처리하며, 개발사는 결제 정보(카드 번호, 계좌 정보 등)를 수집하거나 저장하지 않습니다.
|
||||||
|
|
||||||
|
- Google Play 개인정보 처리방침: https://policies.google.com/privacy
|
||||||
|
- Apple 개인정보 처리방침: https://www.apple.com/legal/privacy/
|
||||||
|
|
||||||
|
## 5. 권한 사용
|
||||||
|
|
||||||
|
| 권한 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| 네트워크 접근 | 광고 표시 및 인앱 결제 처리 |
|
||||||
|
| 저장소 접근 | 게임 데이터 저장 |
|
||||||
|
|
||||||
|
요청된 권한은 해당 용도 외에는 사용되지 않습니다.
|
||||||
|
|
||||||
|
## 6. 아동의 개인정보
|
||||||
|
|
||||||
|
본 앱은 일반 사용자를 대상으로 설계되었으며, **만 14세 미만의 아동**을 대상으로 개인정보를 수집하지 않습니다.
|
||||||
|
|
||||||
|
## 7. 개인정보의 보호
|
||||||
|
|
||||||
|
- 모든 게임 데이터는 기기 내부에만 저장
|
||||||
|
- 외부 서버로의 개인정보 전송 없음
|
||||||
|
- 최소한의 필수 권한만 요청
|
||||||
|
|
||||||
|
## 8. 처리방침의 변경
|
||||||
|
|
||||||
|
본 개인정보 처리방침이 변경되는 경우, 앱 내 공지 또는 앱 스토어 설명을 통해 안내합니다.
|
||||||
|
|
||||||
|
## 9. 문의처
|
||||||
|
|
||||||
|
- **이메일:** naturebridgeai@gmail.com
|
||||||
|
- **담당자:** NatureBridgeAI 앱개발팀
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# English
|
||||||
|
|
||||||
|
**Effective Date: January 30, 2026**
|
||||||
|
|
||||||
|
This Privacy Policy describes how ASCII Never Die (the "App") collects, uses, stores, and protects your information.
|
||||||
|
|
||||||
|
## 1. Information We Collect
|
||||||
|
|
||||||
|
This App **does not require account registration or login**. We do not directly collect personal identifying information such as your name, email address, or phone number.
|
||||||
|
|
||||||
|
Character names and game progress data (level, equipment, quests, etc.) that you enter are **stored only on your device**.
|
||||||
|
|
||||||
|
## 2. Data Storage and Processing
|
||||||
|
|
||||||
|
- All game data is stored **locally on your device only**.
|
||||||
|
- No data is automatically transmitted to cloud services or external servers.
|
||||||
|
- All stored data is deleted when you uninstall the App.
|
||||||
|
|
||||||
|
## 3. Advertising and Third-Party Services
|
||||||
|
|
||||||
|
This App uses the **Google AdMob** advertising network. The following information may be collected for advertising purposes:
|
||||||
|
|
||||||
|
- Advertising ID
|
||||||
|
- Device information (model, OS version, etc.)
|
||||||
|
- Approximate location information
|
||||||
|
- App usage information
|
||||||
|
|
||||||
|
This information is processed in accordance with Google's Privacy Policy.
|
||||||
|
- Google Privacy Policy: https://policies.google.com/privacy
|
||||||
|
|
||||||
|
## 4. In-App Purchases
|
||||||
|
|
||||||
|
This App offers in-app purchases for features such as **ad removal**. Payment processing is handled directly by each platform (Google Play, Apple App Store). We do not collect or store any payment information (credit card numbers, account details, etc.).
|
||||||
|
|
||||||
|
- Google Play Privacy Policy: https://policies.google.com/privacy
|
||||||
|
- Apple Privacy Policy: https://www.apple.com/legal/privacy/
|
||||||
|
|
||||||
|
## 5. Permissions
|
||||||
|
|
||||||
|
| Permission | Purpose |
|
||||||
|
|------------|---------|
|
||||||
|
| Network Access | Display advertisements and process in-app purchases |
|
||||||
|
| Storage Access | Save game data |
|
||||||
|
|
||||||
|
Requested permissions are not used for any purposes other than those stated above.
|
||||||
|
|
||||||
|
## 6. Children's Privacy
|
||||||
|
|
||||||
|
This App is designed for general users and **does not knowingly collect personal information from children under 14 years of age**.
|
||||||
|
|
||||||
|
## 7. Data Protection
|
||||||
|
|
||||||
|
- All game data is stored only on your device
|
||||||
|
- No personal information is transmitted to external servers
|
||||||
|
- Only essential permissions are requested
|
||||||
|
|
||||||
|
## 8. Changes to This Privacy Policy
|
||||||
|
|
||||||
|
If this Privacy Policy is modified, we will notify you through in-app announcements or app store descriptions.
|
||||||
|
|
||||||
|
## 9. Contact Us
|
||||||
|
|
||||||
|
- **Email:** naturebridgeai@gmail.com
|
||||||
|
- **Contact:** NatureBridgeAI App Development Team
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 日本語 (Japanese)
|
||||||
|
|
||||||
|
**施行日:2026年1月30日**
|
||||||
|
|
||||||
|
本プライバシーポリシーは、ASCII Never Dieアプリ(以下「本アプリ」)における個人情報の収集、利用、保管、保護について説明します。
|
||||||
|
|
||||||
|
## 1. 収集する個人情報
|
||||||
|
|
||||||
|
本アプリは**会員登録・ログイン機能がありません**。氏名、メールアドレス、電話番号などの個人識別情報を直接収集することはありません。
|
||||||
|
|
||||||
|
ユーザーが入力するキャラクター名、ゲーム進行データ(レベル、装備、クエストなど)は**端末内にのみ保存**されます。
|
||||||
|
|
||||||
|
## 2. データの保存と処理方法
|
||||||
|
|
||||||
|
- すべてのゲームデータは**ローカルストレージ(端末内)**にのみ保管されます。
|
||||||
|
- クラウドや外部サーバーへ自動送信されることはありません。
|
||||||
|
- アプリを削除すると、保存されたすべてのデータも削除されます。
|
||||||
|
|
||||||
|
## 3. 広告および第三者サービス
|
||||||
|
|
||||||
|
本アプリは**Google AdMob**広告ネットワークを使用しています。広告サービス提供のため、以下の情報が収集される場合があります:
|
||||||
|
|
||||||
|
- 広告識別子(Advertising ID)
|
||||||
|
- 端末情報(機種、OSバージョンなど)
|
||||||
|
- おおよその位置情報
|
||||||
|
- アプリ使用情報
|
||||||
|
|
||||||
|
これらの情報はGoogleのプライバシーポリシーに従って処理されます。
|
||||||
|
- Googleプライバシーポリシー:https://policies.google.com/privacy
|
||||||
|
|
||||||
|
## 4. アプリ内課金
|
||||||
|
|
||||||
|
本アプリは**広告削除**などの機能のためにアプリ内課金を提供しています。決済処理は各プラットフォーム(Google Play、Apple App Store)が直接行い、開発者は決済情報(カード番号、口座情報など)を収集・保存しません。
|
||||||
|
|
||||||
|
- Google Playプライバシーポリシー:https://policies.google.com/privacy
|
||||||
|
- Appleプライバシーポリシー:https://www.apple.com/legal/privacy/
|
||||||
|
|
||||||
|
## 5. 権限の使用
|
||||||
|
|
||||||
|
| 権限 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| ネットワークアクセス | 広告表示およびアプリ内課金処理 |
|
||||||
|
| ストレージアクセス | ゲームデータの保存 |
|
||||||
|
|
||||||
|
要求された権限は、上記の用途以外には使用されません。
|
||||||
|
|
||||||
|
## 6. 児童の個人情報
|
||||||
|
|
||||||
|
本アプリは一般ユーザーを対象として設計されており、**14歳未満の児童**から個人情報を収集することはありません。
|
||||||
|
|
||||||
|
## 7. 個人情報の保護
|
||||||
|
|
||||||
|
- すべてのゲームデータは端末内にのみ保存
|
||||||
|
- 外部サーバーへの個人情報送信なし
|
||||||
|
- 最小限の必要な権限のみを要求
|
||||||
|
|
||||||
|
## 8. プライバシーポリシーの変更
|
||||||
|
|
||||||
|
本プライバシーポリシーが変更される場合、アプリ内通知またはアプリストアの説明を通じてお知らせします。
|
||||||
|
|
||||||
|
## 9. お問い合わせ
|
||||||
|
|
||||||
|
- **メール:** naturebridgeai@gmail.com
|
||||||
|
- **担当者:** NatureBridgeAI アプリ開発チーム
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: January 30, 2026*
|
||||||
208
docs/ARCHITECTURE.md
Normal file
208
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# 아키텍처 문서
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── main.dart # 앱 진입점
|
||||||
|
├── data/ # 정적 데이터 (Config.dfm 추출)
|
||||||
|
│ ├── pq_config_data.dart
|
||||||
|
│ ├── race_data.dart
|
||||||
|
│ ├── class_data.dart
|
||||||
|
│ └── skill_data.dart
|
||||||
|
├── l10n/ # i18n 생성 파일
|
||||||
|
└── src/
|
||||||
|
├── app.dart # MaterialApp 설정
|
||||||
|
├── core/ # 도메인 레이어
|
||||||
|
│ ├── animation/ # ASCII 애니메이션
|
||||||
|
│ ├── audio/ # 오디오 서비스
|
||||||
|
│ ├── engine/ # 게임 로직
|
||||||
|
│ ├── model/ # 데이터 모델
|
||||||
|
│ ├── storage/ # 저장/로드
|
||||||
|
│ └── util/ # 유틸리티
|
||||||
|
├── features/ # 프레젠테이션 레이어
|
||||||
|
│ ├── arena/ # 아레나 화면
|
||||||
|
│ ├── front/ # 프론트 화면
|
||||||
|
│ ├── game/ # 게임 화면
|
||||||
|
│ ├── hall_of_fame/ # 명예의 전당
|
||||||
|
│ ├── new_character/ # 캐릭터 생성
|
||||||
|
│ └── settings/ # 설정
|
||||||
|
└── shared/ # 공통 위젯/스타일
|
||||||
|
```
|
||||||
|
|
||||||
|
## 레이어 구조 (Clean Architecture)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Presentation Layer │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ Screens │ │ Widgets │ │ Controllers │ │
|
||||||
|
│ │ (features/)│ │ (widgets/) │ │ (game_session_ │ │
|
||||||
|
│ │ │ │ │ │ controller.dart) │ │
|
||||||
|
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
|
||||||
|
└─────────┼────────────────┼───────────────────┼──────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Domain Layer │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ Models │ │ Services │ │ Managers │ │
|
||||||
|
│ │ (model/) │ │ (engine/) │ │ (game/managers/) │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ │ - GameState │ │ - Progress │ │ - Statistics │ │
|
||||||
|
│ │ - Equipment │ │ - Combat │ │ - SpeedBoost │ │
|
||||||
|
│ │ - Skills │ │ - Item │ │ - ReturnRewards │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ │ - Resurrection │ │
|
||||||
|
│ │ - HallOfFame │ │
|
||||||
|
│ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Data Layer │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ Static │ │ Storage │ │ External │ │
|
||||||
|
│ │ Data │ │ │ │ Services │ │
|
||||||
|
│ │ (data/) │ │ (storage/) │ │ │ │
|
||||||
|
│ │ │ │ │ │ - AdService │ │
|
||||||
|
│ │ - Config │ │ - SaveMgr │ │ - IAPService │ │
|
||||||
|
│ │ - Races │ │ - HallOfFame│ │ - AudioService │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## GameSessionController 매니저 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ GameSessionController │
|
||||||
|
│ (526 LOC) │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 핵심 책임: │ │
|
||||||
|
│ │ - 게임 루프 관리 (startNew, pause, resume) │ │
|
||||||
|
│ │ - 콜백 처리 (_onPlayerDied, _onGameComplete) │ │
|
||||||
|
│ │ - 상태 관리 (GameState, MonetizationState) │ │
|
||||||
|
│ └────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────────┼───────────────┐ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ Statistics │ │ SpeedBoost │ │ Return │ │
|
||||||
|
│ │ Manager │ │ Manager │ │ Rewards │ │
|
||||||
|
│ │ (140 LOC) │ │ (190 LOC) │ │ (180 LOC) │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │Resurrection │ │ HallOfFame │ │
|
||||||
|
│ │ Manager │ │ Manager │ │
|
||||||
|
│ │ (160 LOC) │ │ (130 LOC) │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 매니저별 책임
|
||||||
|
|
||||||
|
| 매니저 | 파일 | 책임 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `GameStatisticsManager` | `game_statistics_manager.dart` | 세션/누적 통계, 레벨업/골드/처치 추적 |
|
||||||
|
| `SpeedBoostManager` | `speed_boost_manager.dart` | 광고 배속, 버프 만료 체크 |
|
||||||
|
| `ReturnRewardsManager` | `return_rewards_manager.dart` | 복귀 보상 계산, 상자 보상 적용 |
|
||||||
|
| `ResurrectionManager` | `resurrection_manager.dart` | 일반/광고 부활, 자동부활 조건 |
|
||||||
|
| `HallOfFameManager` | `hall_of_fame_manager.dart` | 명예의 전당 등록, 테스트 캐릭터 |
|
||||||
|
|
||||||
|
## 게임 루프 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ startNew() │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ ProgressLoop │◄──────┐
|
||||||
|
│ (50ms tick) │ │
|
||||||
|
└────────┬─────────┘ │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌──────────────────┐ │
|
||||||
|
│ ProgressService │ │
|
||||||
|
│ .tick() │ │
|
||||||
|
└────────┬─────────┘ │
|
||||||
|
│ │
|
||||||
|
┌────┴────┐ │
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ │
|
||||||
|
┌───────┐ ┌───────┐ │
|
||||||
|
│ Task │ │ Quest │ │
|
||||||
|
│Process│ │/Plot │ │
|
||||||
|
└───┬───┘ └───┬───┘ │
|
||||||
|
│ │ │
|
||||||
|
└────┬────┘ │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌──────────────────┐ │
|
||||||
|
│ State Stream │───────┘
|
||||||
|
│ → UI Update │
|
||||||
|
└──────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ AutoSave │
|
||||||
|
│ (30초 간격) │
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
User Action
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ GamePlayScreen │
|
||||||
|
│ (UI Layer) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ GameSession │
|
||||||
|
│ Controller │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌────┴────┬────────┬────────┐
|
||||||
|
│ │ │ │
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐
|
||||||
|
│Progres│ │Stats │ │Resurre│ │HallOf │
|
||||||
|
│sLoop │ │Manager│ │ction │ │Fame │
|
||||||
|
└───┬───┘ └───────┘ │Manager│ │Manager│
|
||||||
|
│ └───────┘ └───────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ProgressService │
|
||||||
|
│ (Game Logic) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ GameState │
|
||||||
|
│ (Immutable) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ SaveManager │
|
||||||
|
│ (Persistence) │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 원본 PQ 알고리즘 매핑
|
||||||
|
|
||||||
|
| PQ 원본 (Delphi) | 포팅 위치 |
|
||||||
|
|-----------------|----------|
|
||||||
|
| `Main.pas:MonsterTask` | `progress_service.dart:_createMonsterTask()` |
|
||||||
|
| `Main.pas:StartTimer` | `progress_loop.dart:tick()` |
|
||||||
|
| `NewGuy.pas:RerollClick` | `character_roll_service.dart` |
|
||||||
|
| `Config.dfm` 데이터 | `data/pq_config_data.dart` |
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
# iOS 최소 배포 대상
|
||||||
# platform :ios, '13.0'
|
platform :ios, '13.0'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
|||||||
@@ -187,6 +187,8 @@
|
|||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
|
ko,
|
||||||
|
ja,
|
||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 97C146E51CF9000F007C117D;
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
@@ -361,14 +363,16 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = 82SY27V867;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.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 +388,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 +405,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 +420,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";
|
||||||
@@ -540,14 +544,16 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = 82SY27V867;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.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";
|
||||||
@@ -562,14 +568,16 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = 82SY27V867;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.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;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Asciineverdie</string>
|
<string>ASCII Never Die</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>asciineverdie</string>
|
<string>ASCII Never Die</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@@ -47,5 +47,26 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>© 2025 naturebridgeai 天安門 六四事件 法輪功 李洪志 Free Tibet</string>
|
<string>© 2025 naturebridgeai 天安門 六四事件 法輪功 李洪志 Free Tibet</string>
|
||||||
|
<!-- AdMob App ID -->
|
||||||
|
<key>GADApplicationIdentifier</key>
|
||||||
|
<string>ca-app-pub-6691216385521068~8216990571</string>
|
||||||
|
<!-- SKAdNetwork -->
|
||||||
|
<key>SKAdNetworkItems</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>SKAdNetworkIdentifier</key>
|
||||||
|
<string>cstr6suwn9.skadnetwork</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<!-- ATT(App Tracking Transparency) 사용자 추적 동의 문구 -->
|
||||||
|
<key>NSUserTrackingUsageDescription</key>
|
||||||
|
<string>This identifier will be used to deliver personalized ads to you.</string>
|
||||||
|
<!-- 지원 언어(localization) 목록 -->
|
||||||
|
<key>CFBundleLocalizations</key>
|
||||||
|
<array>
|
||||||
|
<string>en</string>
|
||||||
|
<string>ko</string>
|
||||||
|
<string>ja</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
1
ios/Runner/en.lproj/InfoPlist.strings
Normal file
1
ios/Runner/en.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"NSUserTrackingUsageDescription" = "This identifier will be used to deliver personalized ads to you.";
|
||||||
1
ios/Runner/ja.lproj/InfoPlist.strings
Normal file
1
ios/Runner/ja.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"NSUserTrackingUsageDescription" = "パーソナライズされた広告を配信するために端末識別子を使用します。";
|
||||||
1
ios/Runner/ko.lproj/InfoPlist.strings
Normal file
1
ios/Runner/ko.lproj/InfoPlist.strings
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"NSUserTrackingUsageDescription" = "맞춤 광고를 제공하기 위해 기기 식별자를 사용합니다.";
|
||||||
@@ -159,7 +159,7 @@ String get speedBoostTitle => _l('Speed Boost', '속도 부스트', 'スピー
|
|||||||
String get speedBoostActivate =>
|
String get speedBoostActivate =>
|
||||||
_l('Activate 10x Speed', '10배속 활성화', '10倍速を有効化');
|
_l('Activate 10x Speed', '10배속 활성화', '10倍速を有効化');
|
||||||
String speedBoostRemaining(int seconds) =>
|
String speedBoostRemaining(int seconds) =>
|
||||||
_l('${seconds}s remaining', '${seconds}초 남음', '残り${seconds}秒');
|
_l('${seconds}s remaining', '$seconds초 남음', '残り$seconds秒');
|
||||||
String get speedBoostActive => _l('BOOST ACTIVE', '부스트 활성화', 'ブースト中');
|
String get speedBoostActive => _l('BOOST ACTIVE', '부스트 활성화', 'ブースト中');
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -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!', '현재보다 좋습니다!', '現在より良い!');
|
||||||
|
|
||||||
@@ -652,8 +650,9 @@ String translateImpressiveTitle(String englishName) {
|
|||||||
/// 특수 아이템 이름 번역
|
/// 특수 아이템 이름 번역
|
||||||
String translateSpecial(String englishName) {
|
String translateSpecial(String englishName) {
|
||||||
if (isKoreanLocale) return specialTranslationsKo[englishName] ?? englishName;
|
if (isKoreanLocale) return specialTranslationsKo[englishName] ?? englishName;
|
||||||
if (isJapaneseLocale)
|
if (isJapaneseLocale) {
|
||||||
return specialTranslationsJa[englishName] ?? englishName;
|
return specialTranslationsJa[englishName] ?? englishName;
|
||||||
|
}
|
||||||
return englishName;
|
return englishName;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -830,54 +829,65 @@ String translateItemNameL10n(String itemString) {
|
|||||||
|
|
||||||
/// Act 제목 번역
|
/// Act 제목 번역
|
||||||
String translateActTitle(String englishTitle) {
|
String translateActTitle(String englishTitle) {
|
||||||
if (isKoreanLocale)
|
if (isKoreanLocale) {
|
||||||
return actTitleTranslationsKo[englishTitle] ?? englishTitle;
|
return actTitleTranslationsKo[englishTitle] ?? englishTitle;
|
||||||
if (isJapaneseLocale)
|
}
|
||||||
|
if (isJapaneseLocale) {
|
||||||
return actTitleTranslationsJa[englishTitle] ?? englishTitle;
|
return actTitleTranslationsJa[englishTitle] ?? englishTitle;
|
||||||
|
}
|
||||||
return englishTitle;
|
return englishTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Act 보스 이름 번역
|
/// Act 보스 이름 번역
|
||||||
String translateActBoss(String englishBoss) {
|
String translateActBoss(String englishBoss) {
|
||||||
if (isKoreanLocale) return actBossTranslationsKo[englishBoss] ?? englishBoss;
|
if (isKoreanLocale) return actBossTranslationsKo[englishBoss] ?? englishBoss;
|
||||||
if (isJapaneseLocale)
|
if (isJapaneseLocale) {
|
||||||
return actBossTranslationsJa[englishBoss] ?? englishBoss;
|
return actBossTranslationsJa[englishBoss] ?? englishBoss;
|
||||||
|
}
|
||||||
return englishBoss;
|
return englishBoss;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Act 퀘스트 번역
|
/// Act 퀘스트 번역
|
||||||
String translateActQuest(String englishQuest) {
|
String translateActQuest(String englishQuest) {
|
||||||
if (isKoreanLocale)
|
if (isKoreanLocale) {
|
||||||
return actQuestTranslationsKo[englishQuest] ?? englishQuest;
|
return actQuestTranslationsKo[englishQuest] ?? englishQuest;
|
||||||
if (isJapaneseLocale)
|
}
|
||||||
|
if (isJapaneseLocale) {
|
||||||
return actQuestTranslationsJa[englishQuest] ?? englishQuest;
|
return actQuestTranslationsJa[englishQuest] ?? englishQuest;
|
||||||
|
}
|
||||||
return englishQuest;
|
return englishQuest;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 시네마틱 텍스트 번역
|
/// 시네마틱 텍스트 번역
|
||||||
String translateCinematic(String englishText) {
|
String translateCinematic(String englishText) {
|
||||||
if (isKoreanLocale)
|
if (isKoreanLocale) {
|
||||||
return cinematicTranslationsKo[englishText] ?? englishText;
|
return cinematicTranslationsKo[englishText] ?? englishText;
|
||||||
if (isJapaneseLocale)
|
}
|
||||||
|
if (isJapaneseLocale) {
|
||||||
return cinematicTranslationsJa[englishText] ?? englishText;
|
return cinematicTranslationsJa[englishText] ?? englishText;
|
||||||
|
}
|
||||||
return englishText;
|
return englishText;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 지역 이름 번역
|
/// 지역 이름 번역
|
||||||
String translateLocation(String englishLocation) {
|
String translateLocation(String englishLocation) {
|
||||||
if (isKoreanLocale)
|
if (isKoreanLocale) {
|
||||||
return locationTranslationsKo[englishLocation] ?? englishLocation;
|
return locationTranslationsKo[englishLocation] ?? englishLocation;
|
||||||
if (isJapaneseLocale)
|
}
|
||||||
|
if (isJapaneseLocale) {
|
||||||
return locationTranslationsJa[englishLocation] ?? englishLocation;
|
return locationTranslationsJa[englishLocation] ?? englishLocation;
|
||||||
|
}
|
||||||
return englishLocation;
|
return englishLocation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 세력/조직 이름 번역
|
/// 세력/조직 이름 번역
|
||||||
String translateFaction(String englishFaction) {
|
String translateFaction(String englishFaction) {
|
||||||
if (isKoreanLocale)
|
if (isKoreanLocale) {
|
||||||
return factionTranslationsKo[englishFaction] ?? englishFaction;
|
return factionTranslationsKo[englishFaction] ?? englishFaction;
|
||||||
if (isJapaneseLocale)
|
}
|
||||||
|
if (isJapaneseLocale) {
|
||||||
return factionTranslationsJa[englishFaction] ?? englishFaction;
|
return factionTranslationsJa[englishFaction] ?? englishFaction;
|
||||||
|
}
|
||||||
return englishFaction;
|
return englishFaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,6 +1033,13 @@ String uiRollHistory(int count) =>
|
|||||||
_l('$count roll(s) in history', '리롤 기록: $count회', 'リロール履歴: $count回');
|
_l('$count roll(s) in history', '리롤 기록: $count회', 'リロール履歴: $count回');
|
||||||
String get uiEnterName =>
|
String get uiEnterName =>
|
||||||
_l('Please enter a name.', '이름을 입력해주세요.', '名前を入力してください。');
|
_l('Please enter a name.', '이름을 입력해주세요.', '名前を入力してください。');
|
||||||
|
String uiUndoAndRolls(int undo, int rolls) => _l(
|
||||||
|
'Undo: $undo | Rolls: $rolls/5',
|
||||||
|
'되돌리기: $undo | 굴리기: $rolls/5',
|
||||||
|
'やり直し: $undo | ロール: $rolls/5',
|
||||||
|
);
|
||||||
|
String uiRollsOnly(int rolls) =>
|
||||||
|
_l('Rolls: $rolls/5', '굴리기: $rolls/5', 'ロール: $rolls/5');
|
||||||
String get uiTestMode => _l('Test Mode', '테스트 모드', 'テストモード');
|
String get uiTestMode => _l('Test Mode', '테스트 모드', 'テストモード');
|
||||||
String get uiTestModeDesc =>
|
String get uiTestModeDesc =>
|
||||||
_l('Use mobile layout on web', '웹에서 모바일 레이아웃 사용', 'Webでモバイルレイアウトを使用');
|
_l('Use mobile layout on web', '웹에서 모바일 레이아웃 사용', 'Webでモバイルレイアウトを使用');
|
||||||
@@ -1143,24 +1160,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', '구매하기', '購入する');
|
||||||
@@ -1226,3 +1234,18 @@ String get elementChaos => _l('Chaos', '혼돈', 'カオス');
|
|||||||
|
|
||||||
// 스킬 상세 정보 없음
|
// 스킬 상세 정보 없음
|
||||||
String get skillNoDetails => _l('No details', '상세 정보 없음', '詳細情報なし');
|
String get skillNoDetails => _l('No details', '상세 정보 없음', '詳細情報なし');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 알림 텍스트 (Notification Texts)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
String get notifyLevelUp => _l('LEVEL UP!', '레벨 업!', 'レベルアップ!');
|
||||||
|
String notifyLevel(int level) => _l('Level $level', '레벨 $level', 'レベル $level');
|
||||||
|
String get notifyQuestComplete => _l('QUEST COMPLETE!', '퀘스트 완료!', 'クエスト完了!');
|
||||||
|
String get notifyPrologueComplete =>
|
||||||
|
_l('PROLOGUE COMPLETE!', '프롤로그 완료!', 'プロローグ完了!');
|
||||||
|
String notifyActComplete(int actNumber) =>
|
||||||
|
_l('ACT $actNumber COMPLETE!', '$actNumber막 완료!', '第$actNumber幕完了!');
|
||||||
|
String get notifyNewSpell => _l('NEW SPELL!', '새 주문!', '新しい呪文!');
|
||||||
|
String get notifyNewEquipment => _l('NEW EQUIPMENT!', '새 장비!', '新しい装備!');
|
||||||
|
String get notifyBossDefeated => _l('BOSS DEFEATED!', '보스 처치!', 'ボス撃破!');
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import 'package:asciineverdie/src/core/model/skill.dart';
|
|||||||
|
|
||||||
/// 게임 내 스킬 정의
|
/// 게임 내 스킬 정의
|
||||||
///
|
///
|
||||||
/// PQ 스펠 70개를 전투 스킬로 매핑
|
/// 68개 전투 스킬 정의
|
||||||
/// 스펠 이름(영문)으로 스킬 조회 가능
|
/// 스킬 ID(영문)로 조회 가능
|
||||||
class SkillData {
|
class SkillData {
|
||||||
SkillData._();
|
SkillData._();
|
||||||
|
|
||||||
@@ -1081,9 +1081,9 @@ class SkillData {
|
|||||||
// 스펠 이름 → 스킬 매핑
|
// 스펠 이름 → 스킬 매핑
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// PQ 스펠 이름으로 스킬 조회
|
/// 스킬 ID로 스킬 조회
|
||||||
///
|
///
|
||||||
/// 스펠 이름(영문)을 키로 사용하여 해당 전투 스킬을 반환
|
/// 스킬 ID(영문)를 키로 사용하여 해당 전투 스킬을 반환
|
||||||
static const Map<String, Skill> spellNameToSkill = {
|
static const Map<String, Skill> spellNameToSkill = {
|
||||||
// 공격 스킬
|
// 공격 스킬
|
||||||
'Stack Trace': stackTrace,
|
'Stack Trace': stackTrace,
|
||||||
|
|||||||
@@ -310,5 +310,401 @@
|
|||||||
"@endingTapToSkip": { "description": "Tap to skip hint" },
|
"@endingTapToSkip": { "description": "Tap to skip hint" },
|
||||||
|
|
||||||
"endingHoldToSpeedUp": "HOLD TO SPEED UP",
|
"endingHoldToSpeedUp": "HOLD TO SPEED UP",
|
||||||
"@endingHoldToSpeedUp": { "description": "Hold to speed up scrolling hint" }
|
"@endingHoldToSpeedUp": { "description": "Hold to speed up scrolling hint" },
|
||||||
|
|
||||||
|
"menuTitle": "MENU",
|
||||||
|
"@menuTitle": { "description": "Menu panel title" },
|
||||||
|
|
||||||
|
"optionsTitle": "OPTIONS",
|
||||||
|
"@optionsTitle": { "description": "Options menu title" },
|
||||||
|
|
||||||
|
"soundTitle": "SOUND",
|
||||||
|
"@soundTitle": { "description": "Sound dialog title" },
|
||||||
|
|
||||||
|
"controlSection": "CONTROL",
|
||||||
|
"@controlSection": { "description": "Control section title" },
|
||||||
|
|
||||||
|
"infoSection": "INFO",
|
||||||
|
"@infoSection": { "description": "Info section title" },
|
||||||
|
|
||||||
|
"settingsSection": "SETTINGS",
|
||||||
|
"@settingsSection": { "description": "Settings section title" },
|
||||||
|
|
||||||
|
"saveExitSection": "SAVE / EXIT",
|
||||||
|
"@saveExitSection": { "description": "Save/Exit section title" },
|
||||||
|
|
||||||
|
"ok": "OK",
|
||||||
|
"@ok": { "description": "OK button" },
|
||||||
|
|
||||||
|
"rechargeButton": "RECHARGE",
|
||||||
|
"@rechargeButton": { "description": "Recharge button" },
|
||||||
|
|
||||||
|
"createButton": "CREATE",
|
||||||
|
"@createButton": { "description": "Create button" },
|
||||||
|
|
||||||
|
"previewTitle": "PREVIEW",
|
||||||
|
"@previewTitle": { "description": "Preview panel title" },
|
||||||
|
|
||||||
|
"nameTitle": "NAME",
|
||||||
|
"@nameTitle": { "description": "Name panel title" },
|
||||||
|
|
||||||
|
"statsTitle": "STATS",
|
||||||
|
"@statsTitle": { "description": "Stats panel title" },
|
||||||
|
|
||||||
|
"raceTitle": "RACE",
|
||||||
|
"@raceTitle": { "description": "Race panel title" },
|
||||||
|
|
||||||
|
"classSection": "CLASS",
|
||||||
|
"@classSection": { "description": "Class panel title" },
|
||||||
|
|
||||||
|
"bgmLabel": "BGM",
|
||||||
|
"@bgmLabel": { "description": "BGM volume label" },
|
||||||
|
|
||||||
|
"sfxLabel": "SFX",
|
||||||
|
"@sfxLabel": { "description": "SFX volume label" },
|
||||||
|
|
||||||
|
"hpLabel": "HP",
|
||||||
|
"@hpLabel": { "description": "HP bar label" },
|
||||||
|
|
||||||
|
"mpLabel": "MP",
|
||||||
|
"@mpLabel": { "description": "MP bar label" },
|
||||||
|
|
||||||
|
"expLabel": "EXP",
|
||||||
|
"@expLabel": { "description": "EXP bar label" },
|
||||||
|
|
||||||
|
"notifyLevelUp": "LEVEL UP!",
|
||||||
|
"@notifyLevelUp": { "description": "Level up notification title" },
|
||||||
|
|
||||||
|
"notifyLevel": "Level {level}",
|
||||||
|
"@notifyLevel": {
|
||||||
|
"description": "Level notification subtitle",
|
||||||
|
"placeholders": {
|
||||||
|
"level": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"notifyQuestComplete": "QUEST COMPLETE!",
|
||||||
|
"@notifyQuestComplete": { "description": "Quest complete notification title" },
|
||||||
|
|
||||||
|
"notifyPrologueComplete": "PROLOGUE COMPLETE!",
|
||||||
|
"@notifyPrologueComplete": { "description": "Prologue complete notification title" },
|
||||||
|
|
||||||
|
"notifyActComplete": "ACT {number} COMPLETE!",
|
||||||
|
"@notifyActComplete": {
|
||||||
|
"description": "Act complete notification title",
|
||||||
|
"placeholders": {
|
||||||
|
"number": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"notifyNewSpell": "NEW SPELL!",
|
||||||
|
"@notifyNewSpell": { "description": "New spell notification title" },
|
||||||
|
|
||||||
|
"notifyNewEquipment": "NEW EQUIPMENT!",
|
||||||
|
"@notifyNewEquipment": { "description": "New equipment notification title" },
|
||||||
|
|
||||||
|
"notifyBossDefeated": "BOSS DEFEATED!",
|
||||||
|
"@notifyBossDefeated": { "description": "Boss defeated notification title" },
|
||||||
|
|
||||||
|
"rechargeRollsTitle": "RECHARGE ROLLS",
|
||||||
|
"@rechargeRollsTitle": { "description": "Recharge rolls dialog title" },
|
||||||
|
|
||||||
|
"rechargeRollsFree": "Recharge 5 rolls for free?",
|
||||||
|
"@rechargeRollsFree": { "description": "Recharge rolls free user message" },
|
||||||
|
|
||||||
|
"rechargeRollsAd": "Watch an ad to recharge 5 rolls?",
|
||||||
|
"@rechargeRollsAd": { "description": "Recharge rolls ad message" },
|
||||||
|
|
||||||
|
"debugTitle": "DEBUG",
|
||||||
|
"@debugTitle": { "description": "Debug section title" },
|
||||||
|
|
||||||
|
"debugCheatsTitle": "DEBUG CHEATS",
|
||||||
|
"@debugCheatsTitle": { "description": "Debug cheats section title" },
|
||||||
|
|
||||||
|
"debugToolsTitle": "DEBUG TOOLS",
|
||||||
|
"@debugToolsTitle": { "description": "Debug tools section title" },
|
||||||
|
|
||||||
|
"debugDeveloperTools": "DEVELOPER TOOLS",
|
||||||
|
"@debugDeveloperTools": { "description": "Developer tools header" },
|
||||||
|
|
||||||
|
"debugSkipTask": "SKIP TASK (L+1)",
|
||||||
|
"@debugSkipTask": { "description": "Skip task cheat label" },
|
||||||
|
|
||||||
|
"debugSkipTaskDesc": "Complete task instantly",
|
||||||
|
"@debugSkipTaskDesc": { "description": "Skip task cheat description" },
|
||||||
|
|
||||||
|
"debugSkipQuest": "SKIP QUEST (Q!)",
|
||||||
|
"@debugSkipQuest": { "description": "Skip quest cheat label" },
|
||||||
|
|
||||||
|
"debugSkipQuestDesc": "Complete quest instantly",
|
||||||
|
"@debugSkipQuestDesc": { "description": "Skip quest cheat description" },
|
||||||
|
|
||||||
|
"debugSkipAct": "SKIP ACT (P!)",
|
||||||
|
"@debugSkipAct": { "description": "Skip act cheat label" },
|
||||||
|
|
||||||
|
"debugSkipActDesc": "Complete act instantly",
|
||||||
|
"@debugSkipActDesc": { "description": "Skip act cheat description" },
|
||||||
|
|
||||||
|
"debugCreateTestCharacter": "CREATE TEST CHARACTER",
|
||||||
|
"@debugCreateTestCharacter": { "description": "Create test character button" },
|
||||||
|
|
||||||
|
"debugCreateTestCharacterDesc": "Register Level 100 character to Hall of Fame",
|
||||||
|
"@debugCreateTestCharacterDesc": { "description": "Create test character description" },
|
||||||
|
|
||||||
|
"debugCreateTestCharacterTitle": "CREATE TEST CHARACTER?",
|
||||||
|
"@debugCreateTestCharacterTitle": { "description": "Create test character dialog title" },
|
||||||
|
|
||||||
|
"debugCreateTestCharacterMessage": "Current character will be converted to Level 100\nand registered to the Hall of Fame.\n\n⚠️ Current save file will be deleted.\nThis action cannot be undone.",
|
||||||
|
"@debugCreateTestCharacterMessage": { "description": "Create test character confirmation message" },
|
||||||
|
|
||||||
|
"debugTurbo": "DEBUG: TURBO (20x)",
|
||||||
|
"@debugTurbo": { "description": "Debug turbo mode label" },
|
||||||
|
|
||||||
|
"debugIapPurchased": "IAP PURCHASED",
|
||||||
|
"@debugIapPurchased": { "description": "IAP purchased debug toggle" },
|
||||||
|
|
||||||
|
"debugIapPurchasedDesc": "ON: Behave as paid user (ads removed)",
|
||||||
|
"@debugIapPurchasedDesc": { "description": "IAP purchased debug description" },
|
||||||
|
|
||||||
|
"debugOfflineHours": "OFFLINE HOURS",
|
||||||
|
"@debugOfflineHours": { "description": "Offline hours debug label" },
|
||||||
|
|
||||||
|
"debugOfflineHoursDesc": "Test return rewards (applies on restart)",
|
||||||
|
"@debugOfflineHoursDesc": { "description": "Offline hours debug description" },
|
||||||
|
|
||||||
|
"debugTestCharacterDesc": "Modify current character to Level 100\nand register to the Hall of Fame.",
|
||||||
|
"@debugTestCharacterDesc": { "description": "Test character creation description" },
|
||||||
|
|
||||||
|
"arenaTitle": "LOCAL ARENA",
|
||||||
|
"@arenaTitle": { "description": "Arena main screen title" },
|
||||||
|
|
||||||
|
"arenaSelectFighter": "SELECT YOUR FIGHTER",
|
||||||
|
"@arenaSelectFighter": { "description": "Arena character selection subtitle" },
|
||||||
|
|
||||||
|
"arenaEmptyTitle": "Not enough heroes",
|
||||||
|
"@arenaEmptyTitle": { "description": "Arena empty state title" },
|
||||||
|
|
||||||
|
"arenaEmptyHint": "Clear the game with 2+ characters",
|
||||||
|
"@arenaEmptyHint": { "description": "Arena empty state hint" },
|
||||||
|
|
||||||
|
"arenaSetupTitle": "ARENA SETUP",
|
||||||
|
"@arenaSetupTitle": { "description": "Arena setup screen title" },
|
||||||
|
|
||||||
|
"arenaStartBattle": "START BATTLE",
|
||||||
|
"@arenaStartBattle": { "description": "Start battle button" },
|
||||||
|
|
||||||
|
"arenaBattleTitle": "ARENA BATTLE",
|
||||||
|
"@arenaBattleTitle": { "description": "Arena battle screen title" },
|
||||||
|
|
||||||
|
"arenaMyEquipment": "MY EQUIPMENT",
|
||||||
|
"@arenaMyEquipment": { "description": "My equipment header" },
|
||||||
|
|
||||||
|
"arenaEnemyEquipment": "ENEMY EQUIPMENT",
|
||||||
|
"@arenaEnemyEquipment": { "description": "Enemy equipment header" },
|
||||||
|
|
||||||
|
"arenaSelected": "SELECTED",
|
||||||
|
"@arenaSelected": { "description": "Selected slot label" },
|
||||||
|
|
||||||
|
"arenaRecommended": "BEST",
|
||||||
|
"@arenaRecommended": { "description": "Recommended slot label" },
|
||||||
|
|
||||||
|
"arenaWeaponLocked": "LOCKED",
|
||||||
|
"@arenaWeaponLocked": { "description": "Weapon slot locked label" },
|
||||||
|
|
||||||
|
"arenaVictory": "VICTORY!",
|
||||||
|
"@arenaVictory": { "description": "Arena victory title" },
|
||||||
|
|
||||||
|
"arenaDefeat": "DEFEAT...",
|
||||||
|
"@arenaDefeat": { "description": "Arena defeat title" },
|
||||||
|
|
||||||
|
"arenaEquipmentExchange": "EQUIPMENT EXCHANGE",
|
||||||
|
"@arenaEquipmentExchange": { "description": "Equipment exchange section title" },
|
||||||
|
|
||||||
|
"arenaTurns": "TURNS",
|
||||||
|
"@arenaTurns": { "description": "Turns label" },
|
||||||
|
|
||||||
|
"arenaWinner": "WINNER",
|
||||||
|
"@arenaWinner": { "description": "Winner label" },
|
||||||
|
|
||||||
|
"arenaLoser": "LOSER",
|
||||||
|
"@arenaLoser": { "description": "Loser label" },
|
||||||
|
|
||||||
|
"arenaDefeatedIn": "{winner} defeated {loser} in {turns} TURNS",
|
||||||
|
"@arenaDefeatedIn": {
|
||||||
|
"description": "Battle summary text",
|
||||||
|
"placeholders": {
|
||||||
|
"winner": { "type": "String" },
|
||||||
|
"loser": { "type": "String" },
|
||||||
|
"turns": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"arenaScoreGain": "You will GAIN +{score}",
|
||||||
|
"@arenaScoreGain": {
|
||||||
|
"description": "Score gain prediction",
|
||||||
|
"placeholders": {
|
||||||
|
"score": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"arenaScoreLose": "You will LOSE {score}",
|
||||||
|
"@arenaScoreLose": {
|
||||||
|
"description": "Score loss prediction",
|
||||||
|
"placeholders": {
|
||||||
|
"score": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"arenaEvenTrade": "Even trade",
|
||||||
|
"@arenaEvenTrade": { "description": "Even trade label" },
|
||||||
|
|
||||||
|
"arenaScore": "SCORE",
|
||||||
|
"@arenaScore": { "description": "Score label" },
|
||||||
|
|
||||||
|
"statsStatistics": "Statistics",
|
||||||
|
"@statsStatistics": { "description": "Statistics dialog title" },
|
||||||
|
|
||||||
|
"statsSession": "Session",
|
||||||
|
"@statsSession": { "description": "Session tab label" },
|
||||||
|
|
||||||
|
"statsAccumulated": "Total",
|
||||||
|
"@statsAccumulated": { "description": "Accumulated tab label" },
|
||||||
|
|
||||||
|
"statsCombat": "Combat",
|
||||||
|
"@statsCombat": { "description": "Combat section title" },
|
||||||
|
|
||||||
|
"statsPlayTime": "Play Time",
|
||||||
|
"@statsPlayTime": { "description": "Play time label" },
|
||||||
|
|
||||||
|
"statsMonstersKilled": "Monsters Killed",
|
||||||
|
"@statsMonstersKilled": { "description": "Monsters killed label" },
|
||||||
|
|
||||||
|
"statsBossesDefeated": "Bosses Defeated",
|
||||||
|
"@statsBossesDefeated": { "description": "Bosses defeated label" },
|
||||||
|
|
||||||
|
"statsDeaths": "Deaths",
|
||||||
|
"@statsDeaths": { "description": "Deaths label" },
|
||||||
|
|
||||||
|
"statsDamage": "Damage",
|
||||||
|
"@statsDamage": { "description": "Damage section title" },
|
||||||
|
|
||||||
|
"statsDamageDealt": "Damage Dealt",
|
||||||
|
"@statsDamageDealt": { "description": "Damage dealt label" },
|
||||||
|
|
||||||
|
"statsDamageTaken": "Damage Taken",
|
||||||
|
"@statsDamageTaken": { "description": "Damage taken label" },
|
||||||
|
|
||||||
|
"statsAverageDps": "Average DPS",
|
||||||
|
"@statsAverageDps": { "description": "Average DPS label" },
|
||||||
|
|
||||||
|
"statsSkills": "Skills",
|
||||||
|
"@statsSkills": { "description": "Skills section title" },
|
||||||
|
|
||||||
|
"statsSkillsUsed": "Skills Used",
|
||||||
|
"@statsSkillsUsed": { "description": "Skills used label" },
|
||||||
|
|
||||||
|
"statsCriticalHits": "Critical Hits",
|
||||||
|
"@statsCriticalHits": { "description": "Critical hits label" },
|
||||||
|
|
||||||
|
"statsMaxCriticalStreak": "Max Critical Streak",
|
||||||
|
"@statsMaxCriticalStreak": { "description": "Max critical streak label" },
|
||||||
|
|
||||||
|
"statsCriticalRate": "Critical Rate",
|
||||||
|
"@statsCriticalRate": { "description": "Critical rate label" },
|
||||||
|
|
||||||
|
"statsEconomy": "Economy",
|
||||||
|
"@statsEconomy": { "description": "Economy section title" },
|
||||||
|
|
||||||
|
"statsGoldEarned": "Gold Earned",
|
||||||
|
"@statsGoldEarned": { "description": "Gold earned label" },
|
||||||
|
|
||||||
|
"statsGoldSpent": "Gold Spent",
|
||||||
|
"@statsGoldSpent": { "description": "Gold spent label" },
|
||||||
|
|
||||||
|
"statsItemsSold": "Items Sold",
|
||||||
|
"@statsItemsSold": { "description": "Items sold label" },
|
||||||
|
|
||||||
|
"statsPotionsUsed": "Potions Used",
|
||||||
|
"@statsPotionsUsed": { "description": "Potions used label" },
|
||||||
|
|
||||||
|
"statsProgress": "Progress",
|
||||||
|
"@statsProgress": { "description": "Progress section title" },
|
||||||
|
|
||||||
|
"statsLevelUps": "Level Ups",
|
||||||
|
"@statsLevelUps": { "description": "Level ups label" },
|
||||||
|
|
||||||
|
"statsQuestsCompleted": "Quests Completed",
|
||||||
|
"@statsQuestsCompleted": { "description": "Quests completed label" },
|
||||||
|
|
||||||
|
"statsRecords": "Records",
|
||||||
|
"@statsRecords": { "description": "Records section title" },
|
||||||
|
|
||||||
|
"statsHighestLevel": "Highest Level",
|
||||||
|
"@statsHighestLevel": { "description": "Highest level label" },
|
||||||
|
|
||||||
|
"statsHighestGoldHeld": "Highest Gold Held",
|
||||||
|
"@statsHighestGoldHeld": { "description": "Highest gold held label" },
|
||||||
|
|
||||||
|
"statsBestCriticalStreak": "Best Critical Streak",
|
||||||
|
"@statsBestCriticalStreak": { "description": "Best critical streak label" },
|
||||||
|
|
||||||
|
"statsTotalPlay": "Total Play",
|
||||||
|
"@statsTotalPlay": { "description": "Total play section title" },
|
||||||
|
|
||||||
|
"statsTotalPlayTime": "Total Play Time",
|
||||||
|
"@statsTotalPlayTime": { "description": "Total play time label" },
|
||||||
|
|
||||||
|
"statsGamesStarted": "Games Started",
|
||||||
|
"@statsGamesStarted": { "description": "Games started label" },
|
||||||
|
|
||||||
|
"statsGamesCompleted": "Games Completed",
|
||||||
|
"@statsGamesCompleted": { "description": "Games completed label" },
|
||||||
|
|
||||||
|
"statsCompletionRate": "Completion Rate",
|
||||||
|
"@statsCompletionRate": { "description": "Completion rate label" },
|
||||||
|
|
||||||
|
"statsTotalCombat": "Total Combat",
|
||||||
|
"@statsTotalCombat": { "description": "Total combat section title" },
|
||||||
|
|
||||||
|
"statsTotalDeaths": "Total Deaths",
|
||||||
|
"@statsTotalDeaths": { "description": "Total deaths label" },
|
||||||
|
|
||||||
|
"statsTotalLevelUps": "Total Level Ups",
|
||||||
|
"@statsTotalLevelUps": { "description": "Total level ups label" },
|
||||||
|
|
||||||
|
"statsTotalDamage": "Total Damage",
|
||||||
|
"@statsTotalDamage": { "description": "Total damage section title" },
|
||||||
|
|
||||||
|
"statsTotalSkills": "Total Skills",
|
||||||
|
"@statsTotalSkills": { "description": "Total skills section title" },
|
||||||
|
|
||||||
|
"statsTotalEconomy": "Total Economy",
|
||||||
|
"@statsTotalEconomy": { "description": "Total economy section title" },
|
||||||
|
|
||||||
|
"notifyLevelUpLabel": "LEVEL UP",
|
||||||
|
"@notifyLevelUpLabel": { "description": "Level up notification type label" },
|
||||||
|
|
||||||
|
"notifyQuestDoneLabel": "QUEST DONE",
|
||||||
|
"@notifyQuestDoneLabel": { "description": "Quest done notification type label" },
|
||||||
|
|
||||||
|
"notifyActClearLabel": "ACT CLEAR",
|
||||||
|
"@notifyActClearLabel": { "description": "Act clear notification type label" },
|
||||||
|
|
||||||
|
"notifyNewSpellLabel": "NEW SPELL",
|
||||||
|
"@notifyNewSpellLabel": { "description": "New spell notification type label" },
|
||||||
|
|
||||||
|
"notifyNewItemLabel": "NEW ITEM",
|
||||||
|
"@notifyNewItemLabel": { "description": "New item notification type label" },
|
||||||
|
|
||||||
|
"notifyBossSlainLabel": "BOSS SLAIN",
|
||||||
|
"@notifyBossSlainLabel": { "description": "Boss slain notification type label" },
|
||||||
|
|
||||||
|
"notifySavedLabel": "SAVED",
|
||||||
|
"@notifySavedLabel": { "description": "Game saved notification type label" },
|
||||||
|
|
||||||
|
"notifyInfoLabel": "INFO",
|
||||||
|
"@notifyInfoLabel": { "description": "Info notification type label" },
|
||||||
|
|
||||||
|
"notifyWarningLabel": "WARNING",
|
||||||
|
"@notifyWarningLabel": { "description": "Warning notification type label" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,79 +2,79 @@
|
|||||||
"@@locale": "ja",
|
"@@locale": "ja",
|
||||||
|
|
||||||
"appTitle": "アスキー ネバー ダイ",
|
"appTitle": "アスキー ネバー ダイ",
|
||||||
"tagNoNetwork": "No network",
|
"tagNoNetwork": "オフライン",
|
||||||
"tagIdleRpg": "Idle RPG loop",
|
"tagIdleRpg": "放置型RPG",
|
||||||
"tagLocalSaves": "Local saves",
|
"tagLocalSaves": "ローカル保存",
|
||||||
"newCharacter": "New character",
|
"newCharacter": "新規キャラクター",
|
||||||
"loadSave": "Load save",
|
"loadSave": "ロード",
|
||||||
"loadGame": "Load Game",
|
"loadGame": "ゲームをロード",
|
||||||
"viewBuildPlan": "View build plan",
|
"viewBuildPlan": "ビルド計画を見る",
|
||||||
"buildRoadmap": "Build roadmap",
|
"buildRoadmap": "ビルドロードマップ",
|
||||||
"techStack": "Tech stack",
|
"techStack": "技術スタック",
|
||||||
"cancel": "Cancel",
|
"cancel": "キャンセル",
|
||||||
"exitGame": "Exit Game",
|
"exitGame": "ゲーム終了",
|
||||||
"saveProgressQuestion": "Save your progress before leaving?",
|
"saveProgressQuestion": "終了する前にセーブしますか?",
|
||||||
"exitWithoutSaving": "Exit without saving",
|
"exitWithoutSaving": "セーブせずに終了",
|
||||||
"saveAndExit": "Save and Exit",
|
"saveAndExit": "セーブして終了",
|
||||||
"progressQuestTitle": "ASCII NEVER DIE - {name}",
|
"progressQuestTitle": "アスキー ネバー ダイ - {name}",
|
||||||
"levelUp": "Level Up",
|
"levelUp": "レベルアップ",
|
||||||
"completeQuest": "Complete Quest",
|
"completeQuest": "クエスト完了",
|
||||||
"completePlot": "Complete Plot",
|
"completePlot": "プロット完了",
|
||||||
"characterSheet": "Character Sheet",
|
"characterSheet": "キャラクターシート",
|
||||||
"traits": "Traits",
|
"traits": "特性",
|
||||||
"stats": "Stats",
|
"stats": "能力値",
|
||||||
"experience": "Experience",
|
"experience": "経験値",
|
||||||
"xpNeededForNextLevel": "XP needed for next level",
|
"xpNeededForNextLevel": "次のレベルまでの必要XP",
|
||||||
"spellBook": "スキル",
|
"spellBook": "スキル",
|
||||||
"noSpellsYet": "習得したスキルがありません",
|
"noSpellsYet": "習得したスキルがありません",
|
||||||
"equipment": "Equipment",
|
"equipment": "装備",
|
||||||
"inventory": "Inventory",
|
"inventory": "インベントリ",
|
||||||
"encumbrance": "Encumbrance",
|
"encumbrance": "積載量",
|
||||||
"combatLog": "戦闘ログ",
|
"combatLog": "戦闘ログ",
|
||||||
"plotDevelopment": "Plot Development",
|
"plotDevelopment": "ストーリー進行",
|
||||||
"quests": "Quests",
|
"quests": "クエスト",
|
||||||
"traitName": "Name",
|
"traitName": "名前",
|
||||||
"traitRace": "Race",
|
"traitRace": "種族",
|
||||||
"traitClass": "Class",
|
"traitClass": "職業",
|
||||||
"traitLevel": "Level",
|
"traitLevel": "レベル",
|
||||||
"statStr": "STR",
|
"statStr": "STR",
|
||||||
"statCon": "CON",
|
"statCon": "CON",
|
||||||
"statDex": "DEX",
|
"statDex": "DEX",
|
||||||
"statInt": "INT",
|
"statInt": "INT",
|
||||||
"statWis": "WIS",
|
"statWis": "WIS",
|
||||||
"statCha": "CHA",
|
"statCha": "CHA",
|
||||||
"statHpMax": "HP Max",
|
"statHpMax": "HP最大",
|
||||||
"statMpMax": "MP Max",
|
"statMpMax": "MP最大",
|
||||||
"equipWeapon": "Weapon",
|
"equipWeapon": "武器",
|
||||||
"equipShield": "Shield",
|
"equipShield": "盾",
|
||||||
"equipHelm": "Helm",
|
"equipHelm": "兜",
|
||||||
"equipHauberk": "Hauberk",
|
"equipHauberk": "鎧",
|
||||||
"equipBrassairts": "Brassairts",
|
"equipBrassairts": "肩当て",
|
||||||
"equipVambraces": "Vambraces",
|
"equipVambraces": "腕甲",
|
||||||
"equipGauntlets": "Gauntlets",
|
"equipGauntlets": "篭手",
|
||||||
"equipGambeson": "Gambeson",
|
"equipGambeson": "防護服",
|
||||||
"equipCuisses": "Cuisses",
|
"equipCuisses": "腿当て",
|
||||||
"equipGreaves": "Greaves",
|
"equipGreaves": "脛当て",
|
||||||
"equipSollerets": "Sollerets",
|
"equipSollerets": "鉄靴",
|
||||||
"gold": "コイン",
|
"gold": "コイン",
|
||||||
"goldAmount": "コイン: {amount}",
|
"goldAmount": "コイン: {amount}",
|
||||||
"prologue": "Prologue",
|
"prologue": "プロローグ",
|
||||||
"actNumber": "Act {number}",
|
"actNumber": "第{number}幕",
|
||||||
"noActiveQuests": "No active quests",
|
"noActiveQuests": "進行中のクエストなし",
|
||||||
"questNumber": "Quest #{number}",
|
"questNumber": "クエスト #{number}",
|
||||||
"welcomeMessage": "ASCII NEVER DIEへようこそ!",
|
"welcomeMessage": "ASCII NEVER DIEへようこそ!",
|
||||||
"noSavedGames": "No saved games found.",
|
"noSavedGames": "セーブデータがありません。",
|
||||||
"loadError": "Failed to load save file: {error}",
|
"loadError": "セーブファイルの読み込みに失敗しました: {error}",
|
||||||
"name": "Name",
|
"name": "名前",
|
||||||
"generateName": "Generate Name",
|
"generateName": "名前を生成",
|
||||||
"total": "Total",
|
"total": "合計",
|
||||||
"unroll": "元に戻す",
|
"unroll": "元に戻す",
|
||||||
"roll": "Roll",
|
"roll": "ロール",
|
||||||
"race": "Race",
|
"race": "種族",
|
||||||
"classTitle": "Class",
|
"classTitle": "職業",
|
||||||
"percentComplete": "{percent}% complete",
|
"percentComplete": "{percent}% 完了",
|
||||||
"newCharacterTitle": "ASCII NEVER DIE - New Character",
|
"newCharacterTitle": "アスキー ネバー ダイ - 新規キャラクター",
|
||||||
"soldButton": "Sold!",
|
"soldButton": "決定!",
|
||||||
|
|
||||||
"endingCongratulations": "★ おめでとうございます ★",
|
"endingCongratulations": "★ おめでとうございます ★",
|
||||||
"endingGameComplete": "ゲームをクリアしました!",
|
"endingGameComplete": "ゲームをクリアしました!",
|
||||||
@@ -93,5 +93,132 @@
|
|||||||
"endingHallOfFameButton": "殿堂入り",
|
"endingHallOfFameButton": "殿堂入り",
|
||||||
"endingSkip": "スキップ",
|
"endingSkip": "スキップ",
|
||||||
"endingTapToSkip": "タップでスキップ",
|
"endingTapToSkip": "タップでスキップ",
|
||||||
"endingHoldToSpeedUp": "長押しで高速スクロール"
|
"endingHoldToSpeedUp": "長押しで高速スクロール",
|
||||||
|
|
||||||
|
"menuTitle": "メニュー",
|
||||||
|
"optionsTitle": "オプション",
|
||||||
|
"soundTitle": "サウンド",
|
||||||
|
"controlSection": "操作",
|
||||||
|
"infoSection": "情報",
|
||||||
|
"settingsSection": "設定",
|
||||||
|
"saveExitSection": "セーブ / 終了",
|
||||||
|
"ok": "OK",
|
||||||
|
"rechargeButton": "チャージ",
|
||||||
|
"createButton": "作成",
|
||||||
|
"previewTitle": "プレビュー",
|
||||||
|
"nameTitle": "名前",
|
||||||
|
"statsTitle": "能力値",
|
||||||
|
"raceTitle": "種族",
|
||||||
|
"classSection": "職業",
|
||||||
|
"bgmLabel": "BGM",
|
||||||
|
"sfxLabel": "効果音",
|
||||||
|
"hpLabel": "HP",
|
||||||
|
"mpLabel": "MP",
|
||||||
|
"expLabel": "EXP",
|
||||||
|
"notifyLevelUp": "レベルアップ!",
|
||||||
|
"notifyLevel": "レベル {level}",
|
||||||
|
"notifyQuestComplete": "クエスト完了!",
|
||||||
|
"notifyPrologueComplete": "プロローグ完了!",
|
||||||
|
"notifyActComplete": "第{number}幕 完了!",
|
||||||
|
"notifyNewSpell": "新しいスキル!",
|
||||||
|
"notifyNewEquipment": "新しい装備!",
|
||||||
|
"notifyBossDefeated": "ボス撃破!",
|
||||||
|
"rechargeRollsTitle": "ロール回数チャージ",
|
||||||
|
"rechargeRollsFree": "無料で5回チャージしますか?",
|
||||||
|
"rechargeRollsAd": "広告を見て5回チャージしますか?",
|
||||||
|
"debugTitle": "デバッグ",
|
||||||
|
"debugCheatsTitle": "デバッグチート",
|
||||||
|
"debugToolsTitle": "デバッグツール",
|
||||||
|
"debugDeveloperTools": "開発者ツール",
|
||||||
|
"debugSkipTask": "タスクスキップ (L+1)",
|
||||||
|
"debugSkipTaskDesc": "タスクを即時完了",
|
||||||
|
"debugSkipQuest": "クエストスキップ (Q!)",
|
||||||
|
"debugSkipQuestDesc": "クエストを即時完了",
|
||||||
|
"debugSkipAct": "アクトスキップ (P!)",
|
||||||
|
"debugSkipActDesc": "アクトを即時完了",
|
||||||
|
"debugCreateTestCharacter": "テストキャラクター作成",
|
||||||
|
"debugCreateTestCharacterDesc": "レベル100キャラクターを殿堂に登録",
|
||||||
|
"debugCreateTestCharacterTitle": "テストキャラクターを作成しますか?",
|
||||||
|
"debugCreateTestCharacterMessage": "現在のキャラクターがレベル100に変換され\n殿堂に登録されます。\n\n⚠️ 現在のセーブファイルは削除されます。\nこの操作は元に戻せません。",
|
||||||
|
"debugTurbo": "デバッグ: ターボ (20x)",
|
||||||
|
"debugIapPurchased": "IAP購入済み",
|
||||||
|
"debugIapPurchasedDesc": "ON: 有料ユーザーとして動作(広告非表示)",
|
||||||
|
"debugOfflineHours": "オフライン時間",
|
||||||
|
"debugOfflineHoursDesc": "復帰報酬テスト(再起動時に適用)",
|
||||||
|
"debugTestCharacterDesc": "現在のキャラクターをレベル100に変更して\n殿堂に登録します。",
|
||||||
|
|
||||||
|
"arenaTitle": "ローカルアリーナ",
|
||||||
|
"arenaSelectFighter": "ファイターを選択",
|
||||||
|
"arenaEmptyTitle": "ヒーローが不足しています",
|
||||||
|
"arenaEmptyHint": "2人以上のキャラでクリアしてください",
|
||||||
|
"arenaSetupTitle": "アリーナ設定",
|
||||||
|
"arenaStartBattle": "バトル開始",
|
||||||
|
"arenaBattleTitle": "アリーナバトル",
|
||||||
|
"arenaMyEquipment": "自分の装備",
|
||||||
|
"arenaEnemyEquipment": "敵の装備",
|
||||||
|
"arenaSelected": "選択済み",
|
||||||
|
"arenaRecommended": "おすすめ",
|
||||||
|
"arenaWeaponLocked": "ロック",
|
||||||
|
"arenaVictory": "勝利!",
|
||||||
|
"arenaDefeat": "敗北...",
|
||||||
|
"arenaEquipmentExchange": "装備交換",
|
||||||
|
"arenaTurns": "ターン",
|
||||||
|
"arenaWinner": "勝者",
|
||||||
|
"arenaLoser": "敗者",
|
||||||
|
"arenaDefeatedIn": "{winner}が{loser}を{turns}ターンで撃破",
|
||||||
|
"arenaScoreGain": "+{score}獲得予定",
|
||||||
|
"arenaScoreLose": "{score}損失予定",
|
||||||
|
"arenaEvenTrade": "等価交換",
|
||||||
|
"arenaScore": "スコア",
|
||||||
|
|
||||||
|
"statsStatistics": "統計",
|
||||||
|
"statsSession": "セッション",
|
||||||
|
"statsAccumulated": "累積",
|
||||||
|
"statsCombat": "戦闘",
|
||||||
|
"statsPlayTime": "プレイ時間",
|
||||||
|
"statsMonstersKilled": "倒したモンスター",
|
||||||
|
"statsBossesDefeated": "ボス討伐",
|
||||||
|
"statsDeaths": "死亡回数",
|
||||||
|
"statsDamage": "ダメージ",
|
||||||
|
"statsDamageDealt": "与えたダメージ",
|
||||||
|
"statsDamageTaken": "受けたダメージ",
|
||||||
|
"statsAverageDps": "平均DPS",
|
||||||
|
"statsSkills": "スキル",
|
||||||
|
"statsSkillsUsed": "スキル使用",
|
||||||
|
"statsCriticalHits": "クリティカルヒット",
|
||||||
|
"statsMaxCriticalStreak": "最大連続クリティカル",
|
||||||
|
"statsCriticalRate": "クリティカル率",
|
||||||
|
"statsEconomy": "経済",
|
||||||
|
"statsGoldEarned": "獲得ゴールド",
|
||||||
|
"statsGoldSpent": "消費ゴールド",
|
||||||
|
"statsItemsSold": "売却アイテム",
|
||||||
|
"statsPotionsUsed": "ポーション使用",
|
||||||
|
"statsProgress": "進行",
|
||||||
|
"statsLevelUps": "レベルアップ",
|
||||||
|
"statsQuestsCompleted": "完了したクエスト",
|
||||||
|
"statsRecords": "記録",
|
||||||
|
"statsHighestLevel": "最高レベル",
|
||||||
|
"statsHighestGoldHeld": "最大所持ゴールド",
|
||||||
|
"statsBestCriticalStreak": "最高連続クリティカル",
|
||||||
|
"statsTotalPlay": "総プレイ",
|
||||||
|
"statsTotalPlayTime": "総プレイ時間",
|
||||||
|
"statsGamesStarted": "開始したゲーム",
|
||||||
|
"statsGamesCompleted": "クリアしたゲーム",
|
||||||
|
"statsCompletionRate": "クリア率",
|
||||||
|
"statsTotalCombat": "総戦闘",
|
||||||
|
"statsTotalDeaths": "総死亡",
|
||||||
|
"statsTotalLevelUps": "総レベルアップ",
|
||||||
|
"statsTotalDamage": "総ダメージ",
|
||||||
|
"statsTotalSkills": "総スキル",
|
||||||
|
"statsTotalEconomy": "総経済",
|
||||||
|
|
||||||
|
"notifyLevelUpLabel": "レベルアップ",
|
||||||
|
"notifyQuestDoneLabel": "クエスト完了",
|
||||||
|
"notifyActClearLabel": "幕完了",
|
||||||
|
"notifyNewSpellLabel": "新しいスキル",
|
||||||
|
"notifyNewItemLabel": "新しいアイテム",
|
||||||
|
"notifyBossSlainLabel": "ボス撃破",
|
||||||
|
"notifySavedLabel": "セーブ済み",
|
||||||
|
"notifyInfoLabel": "情報",
|
||||||
|
"notifyWarningLabel": "警告"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,5 +93,132 @@
|
|||||||
"endingHallOfFameButton": "명예의 전당",
|
"endingHallOfFameButton": "명예의 전당",
|
||||||
"endingSkip": "건너뛰기",
|
"endingSkip": "건너뛰기",
|
||||||
"endingTapToSkip": "탭하여 건너뛰기",
|
"endingTapToSkip": "탭하여 건너뛰기",
|
||||||
"endingHoldToSpeedUp": "길게 누르면 빨리 스크롤"
|
"endingHoldToSpeedUp": "길게 누르면 빨리 스크롤",
|
||||||
|
|
||||||
|
"menuTitle": "메뉴",
|
||||||
|
"optionsTitle": "옵션",
|
||||||
|
"soundTitle": "사운드",
|
||||||
|
"controlSection": "제어",
|
||||||
|
"infoSection": "정보",
|
||||||
|
"settingsSection": "설정",
|
||||||
|
"saveExitSection": "저장 / 종료",
|
||||||
|
"ok": "확인",
|
||||||
|
"rechargeButton": "충전",
|
||||||
|
"createButton": "생성",
|
||||||
|
"previewTitle": "미리보기",
|
||||||
|
"nameTitle": "이름",
|
||||||
|
"statsTitle": "능력치",
|
||||||
|
"raceTitle": "종족",
|
||||||
|
"classSection": "직업",
|
||||||
|
"bgmLabel": "BGM",
|
||||||
|
"sfxLabel": "효과음",
|
||||||
|
"hpLabel": "HP",
|
||||||
|
"mpLabel": "MP",
|
||||||
|
"expLabel": "경험치",
|
||||||
|
"notifyLevelUp": "레벨 업!",
|
||||||
|
"notifyLevel": "레벨 {level}",
|
||||||
|
"notifyQuestComplete": "퀘스트 완료!",
|
||||||
|
"notifyPrologueComplete": "프롤로그 완료!",
|
||||||
|
"notifyActComplete": "{number}막 완료!",
|
||||||
|
"notifyNewSpell": "새 주문!",
|
||||||
|
"notifyNewEquipment": "새 장비!",
|
||||||
|
"notifyBossDefeated": "보스 처치!",
|
||||||
|
"rechargeRollsTitle": "굴리기 충전",
|
||||||
|
"rechargeRollsFree": "무료로 5회 충전하시겠습니까?",
|
||||||
|
"rechargeRollsAd": "광고를 보고 5회 충전하시겠습니까?",
|
||||||
|
"debugTitle": "디버그",
|
||||||
|
"debugCheatsTitle": "디버그 치트",
|
||||||
|
"debugToolsTitle": "디버그 도구",
|
||||||
|
"debugDeveloperTools": "개발자 도구",
|
||||||
|
"debugSkipTask": "태스크 건너뛰기 (L+1)",
|
||||||
|
"debugSkipTaskDesc": "태스크 즉시 완료",
|
||||||
|
"debugSkipQuest": "퀘스트 건너뛰기 (Q!)",
|
||||||
|
"debugSkipQuestDesc": "퀘스트 즉시 완료",
|
||||||
|
"debugSkipAct": "액트 건너뛰기 (P!)",
|
||||||
|
"debugSkipActDesc": "액트 즉시 완료",
|
||||||
|
"debugCreateTestCharacter": "테스트 캐릭터 생성",
|
||||||
|
"debugCreateTestCharacterDesc": "레벨 100 캐릭터를 명예의 전당에 등록",
|
||||||
|
"debugCreateTestCharacterTitle": "테스트 캐릭터 생성?",
|
||||||
|
"debugCreateTestCharacterMessage": "현재 캐릭터가 레벨 100으로 변환되어\n명예의 전당에 등록됩니다.\n\n⚠️ 현재 세이브 파일이 삭제됩니다.\n이 작업은 되돌릴 수 없습니다.",
|
||||||
|
"debugTurbo": "디버그: 터보 (20x)",
|
||||||
|
"debugIapPurchased": "IAP 구매됨",
|
||||||
|
"debugIapPurchasedDesc": "ON: 유료 유저로 동작 (광고 제거)",
|
||||||
|
"debugOfflineHours": "오프라인 시간",
|
||||||
|
"debugOfflineHoursDesc": "복귀 보상 테스트 (재시작 시 적용)",
|
||||||
|
"debugTestCharacterDesc": "현재 캐릭터를 레벨 100으로 수정하여\n명예의 전당에 등록합니다.",
|
||||||
|
|
||||||
|
"arenaTitle": "로컬 아레나",
|
||||||
|
"arenaSelectFighter": "전사를 선택하세요",
|
||||||
|
"arenaEmptyTitle": "영웅이 부족합니다",
|
||||||
|
"arenaEmptyHint": "2명 이상 캐릭터로 클리어하세요",
|
||||||
|
"arenaSetupTitle": "아레나 설정",
|
||||||
|
"arenaStartBattle": "전투 시작",
|
||||||
|
"arenaBattleTitle": "아레나 전투",
|
||||||
|
"arenaMyEquipment": "내 장비",
|
||||||
|
"arenaEnemyEquipment": "상대 장비",
|
||||||
|
"arenaSelected": "선택됨",
|
||||||
|
"arenaRecommended": "추천",
|
||||||
|
"arenaWeaponLocked": "잠김",
|
||||||
|
"arenaVictory": "승리!",
|
||||||
|
"arenaDefeat": "패배...",
|
||||||
|
"arenaEquipmentExchange": "장비 교환",
|
||||||
|
"arenaTurns": "턴",
|
||||||
|
"arenaWinner": "승자",
|
||||||
|
"arenaLoser": "패자",
|
||||||
|
"arenaDefeatedIn": "{winner}이(가) {loser}을(를) {turns}턴 만에 격파",
|
||||||
|
"arenaScoreGain": "+{score} 획득 예정",
|
||||||
|
"arenaScoreLose": "{score} 손실 예정",
|
||||||
|
"arenaEvenTrade": "등가 교환",
|
||||||
|
"arenaScore": "점수",
|
||||||
|
|
||||||
|
"statsStatistics": "통계",
|
||||||
|
"statsSession": "세션",
|
||||||
|
"statsAccumulated": "누적",
|
||||||
|
"statsCombat": "전투",
|
||||||
|
"statsPlayTime": "플레이 시간",
|
||||||
|
"statsMonstersKilled": "처치한 몬스터",
|
||||||
|
"statsBossesDefeated": "보스 처치",
|
||||||
|
"statsDeaths": "사망 횟수",
|
||||||
|
"statsDamage": "데미지",
|
||||||
|
"statsDamageDealt": "입힌 데미지",
|
||||||
|
"statsDamageTaken": "받은 데미지",
|
||||||
|
"statsAverageDps": "평균 DPS",
|
||||||
|
"statsSkills": "스킬",
|
||||||
|
"statsSkillsUsed": "스킬 사용",
|
||||||
|
"statsCriticalHits": "크리티컬 히트",
|
||||||
|
"statsMaxCriticalStreak": "최대 연속 크리티컬",
|
||||||
|
"statsCriticalRate": "크리티컬 비율",
|
||||||
|
"statsEconomy": "경제",
|
||||||
|
"statsGoldEarned": "획득 골드",
|
||||||
|
"statsGoldSpent": "소비 골드",
|
||||||
|
"statsItemsSold": "판매 아이템",
|
||||||
|
"statsPotionsUsed": "물약 사용",
|
||||||
|
"statsProgress": "진행",
|
||||||
|
"statsLevelUps": "레벨업",
|
||||||
|
"statsQuestsCompleted": "완료한 퀘스트",
|
||||||
|
"statsRecords": "기록",
|
||||||
|
"statsHighestLevel": "최고 레벨",
|
||||||
|
"statsHighestGoldHeld": "최대 보유 골드",
|
||||||
|
"statsBestCriticalStreak": "최고 연속 크리티컬",
|
||||||
|
"statsTotalPlay": "총 플레이",
|
||||||
|
"statsTotalPlayTime": "총 플레이 시간",
|
||||||
|
"statsGamesStarted": "시작한 게임",
|
||||||
|
"statsGamesCompleted": "클리어한 게임",
|
||||||
|
"statsCompletionRate": "클리어율",
|
||||||
|
"statsTotalCombat": "총 전투",
|
||||||
|
"statsTotalDeaths": "총 사망",
|
||||||
|
"statsTotalLevelUps": "총 레벨업",
|
||||||
|
"statsTotalDamage": "총 데미지",
|
||||||
|
"statsTotalSkills": "총 스킬",
|
||||||
|
"statsTotalEconomy": "총 경제",
|
||||||
|
|
||||||
|
"notifyLevelUpLabel": "레벨 업",
|
||||||
|
"notifyQuestDoneLabel": "퀘스트 완료",
|
||||||
|
"notifyActClearLabel": "막 완료",
|
||||||
|
"notifyNewSpellLabel": "새 주문",
|
||||||
|
"notifyNewItemLabel": "새 아이템",
|
||||||
|
"notifyBossSlainLabel": "보스 처치",
|
||||||
|
"notifySavedLabel": "저장됨",
|
||||||
|
"notifyInfoLabel": "정보",
|
||||||
|
"notifyWarningLabel": "경고"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import 'package:intl/intl.dart' as intl;
|
|||||||
import 'app_localizations_en.dart';
|
import 'app_localizations_en.dart';
|
||||||
import 'app_localizations_ja.dart';
|
import 'app_localizations_ja.dart';
|
||||||
import 'app_localizations_ko.dart';
|
import 'app_localizations_ko.dart';
|
||||||
import 'app_localizations_zh.dart';
|
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
|
|
||||||
@@ -98,7 +97,6 @@ abstract class L10n {
|
|||||||
Locale('en'),
|
Locale('en'),
|
||||||
Locale('ja'),
|
Locale('ja'),
|
||||||
Locale('ko'),
|
Locale('ko'),
|
||||||
Locale('zh'),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Application title
|
/// Application title
|
||||||
@@ -652,6 +650,744 @@ abstract class L10n {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'HOLD TO SPEED UP'**
|
/// **'HOLD TO SPEED UP'**
|
||||||
String get endingHoldToSpeedUp;
|
String get endingHoldToSpeedUp;
|
||||||
|
|
||||||
|
/// Menu panel title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'MENU'**
|
||||||
|
String get menuTitle;
|
||||||
|
|
||||||
|
/// Options menu title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'OPTIONS'**
|
||||||
|
String get optionsTitle;
|
||||||
|
|
||||||
|
/// Sound dialog title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SOUND'**
|
||||||
|
String get soundTitle;
|
||||||
|
|
||||||
|
/// Control section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'CONTROL'**
|
||||||
|
String get controlSection;
|
||||||
|
|
||||||
|
/// Info section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'INFO'**
|
||||||
|
String get infoSection;
|
||||||
|
|
||||||
|
/// Settings section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SETTINGS'**
|
||||||
|
String get settingsSection;
|
||||||
|
|
||||||
|
/// Save/Exit section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SAVE / EXIT'**
|
||||||
|
String get saveExitSection;
|
||||||
|
|
||||||
|
/// OK button
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'OK'**
|
||||||
|
String get ok;
|
||||||
|
|
||||||
|
/// Recharge button
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'RECHARGE'**
|
||||||
|
String get rechargeButton;
|
||||||
|
|
||||||
|
/// Create button
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'CREATE'**
|
||||||
|
String get createButton;
|
||||||
|
|
||||||
|
/// Preview panel title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'PREVIEW'**
|
||||||
|
String get previewTitle;
|
||||||
|
|
||||||
|
/// Name panel title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'NAME'**
|
||||||
|
String get nameTitle;
|
||||||
|
|
||||||
|
/// Stats panel title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'STATS'**
|
||||||
|
String get statsTitle;
|
||||||
|
|
||||||
|
/// Race panel title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'RACE'**
|
||||||
|
String get raceTitle;
|
||||||
|
|
||||||
|
/// Class panel title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'CLASS'**
|
||||||
|
String get classSection;
|
||||||
|
|
||||||
|
/// BGM volume label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'BGM'**
|
||||||
|
String get bgmLabel;
|
||||||
|
|
||||||
|
/// SFX volume label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SFX'**
|
||||||
|
String get sfxLabel;
|
||||||
|
|
||||||
|
/// HP bar label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'HP'**
|
||||||
|
String get hpLabel;
|
||||||
|
|
||||||
|
/// MP bar label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'MP'**
|
||||||
|
String get mpLabel;
|
||||||
|
|
||||||
|
/// EXP bar label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'EXP'**
|
||||||
|
String get expLabel;
|
||||||
|
|
||||||
|
/// Level up notification title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'LEVEL UP!'**
|
||||||
|
String get notifyLevelUp;
|
||||||
|
|
||||||
|
/// Level notification subtitle
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Level {level}'**
|
||||||
|
String notifyLevel(int level);
|
||||||
|
|
||||||
|
/// Quest complete notification title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'QUEST COMPLETE!'**
|
||||||
|
String get notifyQuestComplete;
|
||||||
|
|
||||||
|
/// Prologue complete notification title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'PROLOGUE COMPLETE!'**
|
||||||
|
String get notifyPrologueComplete;
|
||||||
|
|
||||||
|
/// Act complete notification title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'ACT {number} COMPLETE!'**
|
||||||
|
String notifyActComplete(int number);
|
||||||
|
|
||||||
|
/// New spell notification title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'NEW SPELL!'**
|
||||||
|
String get notifyNewSpell;
|
||||||
|
|
||||||
|
/// New equipment notification title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'NEW EQUIPMENT!'**
|
||||||
|
String get notifyNewEquipment;
|
||||||
|
|
||||||
|
/// Boss defeated notification title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'BOSS DEFEATED!'**
|
||||||
|
String get notifyBossDefeated;
|
||||||
|
|
||||||
|
/// Recharge rolls dialog title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'RECHARGE ROLLS'**
|
||||||
|
String get rechargeRollsTitle;
|
||||||
|
|
||||||
|
/// Recharge rolls free user message
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Recharge 5 rolls for free?'**
|
||||||
|
String get rechargeRollsFree;
|
||||||
|
|
||||||
|
/// Recharge rolls ad message
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Watch an ad to recharge 5 rolls?'**
|
||||||
|
String get rechargeRollsAd;
|
||||||
|
|
||||||
|
/// Debug section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'DEBUG'**
|
||||||
|
String get debugTitle;
|
||||||
|
|
||||||
|
/// Debug cheats section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'DEBUG CHEATS'**
|
||||||
|
String get debugCheatsTitle;
|
||||||
|
|
||||||
|
/// Debug tools section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'DEBUG TOOLS'**
|
||||||
|
String get debugToolsTitle;
|
||||||
|
|
||||||
|
/// Developer tools header
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'DEVELOPER TOOLS'**
|
||||||
|
String get debugDeveloperTools;
|
||||||
|
|
||||||
|
/// Skip task cheat label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SKIP TASK (L+1)'**
|
||||||
|
String get debugSkipTask;
|
||||||
|
|
||||||
|
/// Skip task cheat description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Complete task instantly'**
|
||||||
|
String get debugSkipTaskDesc;
|
||||||
|
|
||||||
|
/// Skip quest cheat label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SKIP QUEST (Q!)'**
|
||||||
|
String get debugSkipQuest;
|
||||||
|
|
||||||
|
/// Skip quest cheat description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Complete quest instantly'**
|
||||||
|
String get debugSkipQuestDesc;
|
||||||
|
|
||||||
|
/// Skip act cheat label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SKIP ACT (P!)'**
|
||||||
|
String get debugSkipAct;
|
||||||
|
|
||||||
|
/// Skip act cheat description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Complete act instantly'**
|
||||||
|
String get debugSkipActDesc;
|
||||||
|
|
||||||
|
/// Create test character button
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'CREATE TEST CHARACTER'**
|
||||||
|
String get debugCreateTestCharacter;
|
||||||
|
|
||||||
|
/// Create test character description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Register Level 100 character to Hall of Fame'**
|
||||||
|
String get debugCreateTestCharacterDesc;
|
||||||
|
|
||||||
|
/// Create test character dialog title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'CREATE TEST CHARACTER?'**
|
||||||
|
String get debugCreateTestCharacterTitle;
|
||||||
|
|
||||||
|
/// Create test character confirmation message
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Current character will be converted to Level 100\nand registered to the Hall of Fame.\n\n⚠️ Current save file will be deleted.\nThis action cannot be undone.'**
|
||||||
|
String get debugCreateTestCharacterMessage;
|
||||||
|
|
||||||
|
/// Debug turbo mode label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'DEBUG: TURBO (20x)'**
|
||||||
|
String get debugTurbo;
|
||||||
|
|
||||||
|
/// IAP purchased debug toggle
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'IAP PURCHASED'**
|
||||||
|
String get debugIapPurchased;
|
||||||
|
|
||||||
|
/// IAP purchased debug description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'ON: Behave as paid user (ads removed)'**
|
||||||
|
String get debugIapPurchasedDesc;
|
||||||
|
|
||||||
|
/// Offline hours debug label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'OFFLINE HOURS'**
|
||||||
|
String get debugOfflineHours;
|
||||||
|
|
||||||
|
/// Offline hours debug description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Test return rewards (applies on restart)'**
|
||||||
|
String get debugOfflineHoursDesc;
|
||||||
|
|
||||||
|
/// Test character creation description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Modify current character to Level 100\nand register to the Hall of Fame.'**
|
||||||
|
String get debugTestCharacterDesc;
|
||||||
|
|
||||||
|
/// Arena main screen title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'LOCAL ARENA'**
|
||||||
|
String get arenaTitle;
|
||||||
|
|
||||||
|
/// Arena character selection subtitle
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SELECT YOUR FIGHTER'**
|
||||||
|
String get arenaSelectFighter;
|
||||||
|
|
||||||
|
/// Arena empty state title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Not enough heroes'**
|
||||||
|
String get arenaEmptyTitle;
|
||||||
|
|
||||||
|
/// Arena empty state hint
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear the game with 2+ characters'**
|
||||||
|
String get arenaEmptyHint;
|
||||||
|
|
||||||
|
/// Arena setup screen title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'ARENA SETUP'**
|
||||||
|
String get arenaSetupTitle;
|
||||||
|
|
||||||
|
/// Start battle button
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'START BATTLE'**
|
||||||
|
String get arenaStartBattle;
|
||||||
|
|
||||||
|
/// Arena battle screen title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'ARENA BATTLE'**
|
||||||
|
String get arenaBattleTitle;
|
||||||
|
|
||||||
|
/// My equipment header
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'MY EQUIPMENT'**
|
||||||
|
String get arenaMyEquipment;
|
||||||
|
|
||||||
|
/// Enemy equipment header
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'ENEMY EQUIPMENT'**
|
||||||
|
String get arenaEnemyEquipment;
|
||||||
|
|
||||||
|
/// Selected slot label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SELECTED'**
|
||||||
|
String get arenaSelected;
|
||||||
|
|
||||||
|
/// Recommended slot label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'BEST'**
|
||||||
|
String get arenaRecommended;
|
||||||
|
|
||||||
|
/// Weapon slot locked label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'LOCKED'**
|
||||||
|
String get arenaWeaponLocked;
|
||||||
|
|
||||||
|
/// Arena victory title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'VICTORY!'**
|
||||||
|
String get arenaVictory;
|
||||||
|
|
||||||
|
/// Arena defeat title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'DEFEAT...'**
|
||||||
|
String get arenaDefeat;
|
||||||
|
|
||||||
|
/// Equipment exchange section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'EQUIPMENT EXCHANGE'**
|
||||||
|
String get arenaEquipmentExchange;
|
||||||
|
|
||||||
|
/// Turns label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'TURNS'**
|
||||||
|
String get arenaTurns;
|
||||||
|
|
||||||
|
/// Winner label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'WINNER'**
|
||||||
|
String get arenaWinner;
|
||||||
|
|
||||||
|
/// Loser label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'LOSER'**
|
||||||
|
String get arenaLoser;
|
||||||
|
|
||||||
|
/// Battle summary text
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{winner} defeated {loser} in {turns} TURNS'**
|
||||||
|
String arenaDefeatedIn(String winner, String loser, int turns);
|
||||||
|
|
||||||
|
/// Score gain prediction
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'You will GAIN +{score}'**
|
||||||
|
String arenaScoreGain(int score);
|
||||||
|
|
||||||
|
/// Score loss prediction
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'You will LOSE {score}'**
|
||||||
|
String arenaScoreLose(int score);
|
||||||
|
|
||||||
|
/// Even trade label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Even trade'**
|
||||||
|
String get arenaEvenTrade;
|
||||||
|
|
||||||
|
/// Score label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SCORE'**
|
||||||
|
String get arenaScore;
|
||||||
|
|
||||||
|
/// Statistics dialog title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Statistics'**
|
||||||
|
String get statsStatistics;
|
||||||
|
|
||||||
|
/// Session tab label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Session'**
|
||||||
|
String get statsSession;
|
||||||
|
|
||||||
|
/// Accumulated tab label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Total'**
|
||||||
|
String get statsAccumulated;
|
||||||
|
|
||||||
|
/// Combat section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Combat'**
|
||||||
|
String get statsCombat;
|
||||||
|
|
||||||
|
/// Play time label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Play Time'**
|
||||||
|
String get statsPlayTime;
|
||||||
|
|
||||||
|
/// Monsters killed label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Monsters Killed'**
|
||||||
|
String get statsMonstersKilled;
|
||||||
|
|
||||||
|
/// Bosses defeated label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bosses Defeated'**
|
||||||
|
String get statsBossesDefeated;
|
||||||
|
|
||||||
|
/// Deaths label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Deaths'**
|
||||||
|
String get statsDeaths;
|
||||||
|
|
||||||
|
/// Damage section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Damage'**
|
||||||
|
String get statsDamage;
|
||||||
|
|
||||||
|
/// Damage dealt label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Damage Dealt'**
|
||||||
|
String get statsDamageDealt;
|
||||||
|
|
||||||
|
/// Damage taken label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Damage Taken'**
|
||||||
|
String get statsDamageTaken;
|
||||||
|
|
||||||
|
/// Average DPS label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Average DPS'**
|
||||||
|
String get statsAverageDps;
|
||||||
|
|
||||||
|
/// Skills section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Skills'**
|
||||||
|
String get statsSkills;
|
||||||
|
|
||||||
|
/// Skills used label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Skills Used'**
|
||||||
|
String get statsSkillsUsed;
|
||||||
|
|
||||||
|
/// Critical hits label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Critical Hits'**
|
||||||
|
String get statsCriticalHits;
|
||||||
|
|
||||||
|
/// Max critical streak label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Max Critical Streak'**
|
||||||
|
String get statsMaxCriticalStreak;
|
||||||
|
|
||||||
|
/// Critical rate label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Critical Rate'**
|
||||||
|
String get statsCriticalRate;
|
||||||
|
|
||||||
|
/// Economy section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Economy'**
|
||||||
|
String get statsEconomy;
|
||||||
|
|
||||||
|
/// Gold earned label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Gold Earned'**
|
||||||
|
String get statsGoldEarned;
|
||||||
|
|
||||||
|
/// Gold spent label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Gold Spent'**
|
||||||
|
String get statsGoldSpent;
|
||||||
|
|
||||||
|
/// Items sold label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Items Sold'**
|
||||||
|
String get statsItemsSold;
|
||||||
|
|
||||||
|
/// Potions used label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Potions Used'**
|
||||||
|
String get statsPotionsUsed;
|
||||||
|
|
||||||
|
/// Progress section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Progress'**
|
||||||
|
String get statsProgress;
|
||||||
|
|
||||||
|
/// Level ups label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Level Ups'**
|
||||||
|
String get statsLevelUps;
|
||||||
|
|
||||||
|
/// Quests completed label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Quests Completed'**
|
||||||
|
String get statsQuestsCompleted;
|
||||||
|
|
||||||
|
/// Records section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Records'**
|
||||||
|
String get statsRecords;
|
||||||
|
|
||||||
|
/// Highest level label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Highest Level'**
|
||||||
|
String get statsHighestLevel;
|
||||||
|
|
||||||
|
/// Highest gold held label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Highest Gold Held'**
|
||||||
|
String get statsHighestGoldHeld;
|
||||||
|
|
||||||
|
/// Best critical streak label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Best Critical Streak'**
|
||||||
|
String get statsBestCriticalStreak;
|
||||||
|
|
||||||
|
/// Total play section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Total Play'**
|
||||||
|
String get statsTotalPlay;
|
||||||
|
|
||||||
|
/// Total play time label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Total Play Time'**
|
||||||
|
String get statsTotalPlayTime;
|
||||||
|
|
||||||
|
/// Games started label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Games Started'**
|
||||||
|
String get statsGamesStarted;
|
||||||
|
|
||||||
|
/// Games completed label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Games Completed'**
|
||||||
|
String get statsGamesCompleted;
|
||||||
|
|
||||||
|
/// Completion rate label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Completion Rate'**
|
||||||
|
String get statsCompletionRate;
|
||||||
|
|
||||||
|
/// Total combat section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Total Combat'**
|
||||||
|
String get statsTotalCombat;
|
||||||
|
|
||||||
|
/// Total deaths label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Total Deaths'**
|
||||||
|
String get statsTotalDeaths;
|
||||||
|
|
||||||
|
/// Total level ups label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Total Level Ups'**
|
||||||
|
String get statsTotalLevelUps;
|
||||||
|
|
||||||
|
/// Total damage section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Total Damage'**
|
||||||
|
String get statsTotalDamage;
|
||||||
|
|
||||||
|
/// Total skills section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Total Skills'**
|
||||||
|
String get statsTotalSkills;
|
||||||
|
|
||||||
|
/// Total economy section title
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Total Economy'**
|
||||||
|
String get statsTotalEconomy;
|
||||||
|
|
||||||
|
/// Level up notification type label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'LEVEL UP'**
|
||||||
|
String get notifyLevelUpLabel;
|
||||||
|
|
||||||
|
/// Quest done notification type label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'QUEST DONE'**
|
||||||
|
String get notifyQuestDoneLabel;
|
||||||
|
|
||||||
|
/// Act clear notification type label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'ACT CLEAR'**
|
||||||
|
String get notifyActClearLabel;
|
||||||
|
|
||||||
|
/// New spell notification type label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'NEW SPELL'**
|
||||||
|
String get notifyNewSpellLabel;
|
||||||
|
|
||||||
|
/// New item notification type label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'NEW ITEM'**
|
||||||
|
String get notifyNewItemLabel;
|
||||||
|
|
||||||
|
/// Boss slain notification type label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'BOSS SLAIN'**
|
||||||
|
String get notifyBossSlainLabel;
|
||||||
|
|
||||||
|
/// Game saved notification type label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'SAVED'**
|
||||||
|
String get notifySavedLabel;
|
||||||
|
|
||||||
|
/// Info notification type label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'INFO'**
|
||||||
|
String get notifyInfoLabel;
|
||||||
|
|
||||||
|
/// Warning notification type label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'WARNING'**
|
||||||
|
String get notifyWarningLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _L10nDelegate extends LocalizationsDelegate<L10n> {
|
class _L10nDelegate extends LocalizationsDelegate<L10n> {
|
||||||
@@ -664,7 +1400,7 @@ class _L10nDelegate extends LocalizationsDelegate<L10n> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool isSupported(Locale locale) =>
|
bool isSupported(Locale locale) =>
|
||||||
<String>['en', 'ja', 'ko', 'zh'].contains(locale.languageCode);
|
<String>['en', 'ja', 'ko'].contains(locale.languageCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldReload(_L10nDelegate old) => false;
|
bool shouldReload(_L10nDelegate old) => false;
|
||||||
@@ -679,8 +1415,6 @@ L10n lookupL10n(Locale locale) {
|
|||||||
return L10nJa();
|
return L10nJa();
|
||||||
case 'ko':
|
case 'ko':
|
||||||
return L10nKo();
|
return L10nKo();
|
||||||
case 'zh':
|
|
||||||
return L10nZh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw FlutterError(
|
throw FlutterError(
|
||||||
|
|||||||
@@ -297,4 +297,387 @@ class L10nEn extends L10n {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get endingHoldToSpeedUp => 'HOLD TO SPEED UP';
|
String get endingHoldToSpeedUp => 'HOLD TO SPEED UP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get menuTitle => 'MENU';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsTitle => 'OPTIONS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get soundTitle => 'SOUND';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get controlSection => 'CONTROL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get infoSection => 'INFO';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsSection => 'SETTINGS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveExitSection => 'SAVE / EXIT';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ok => 'OK';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeButton => 'RECHARGE';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get createButton => 'CREATE';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewTitle => 'PREVIEW';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nameTitle => 'NAME';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTitle => 'STATS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get raceTitle => 'RACE';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get classSection => 'CLASS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bgmLabel => 'BGM';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sfxLabel => 'SFX';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hpLabel => 'HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mpLabel => 'MP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get expLabel => 'EXP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyLevelUp => 'LEVEL UP!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifyLevel(int level) {
|
||||||
|
return 'Level $level';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyQuestComplete => 'QUEST COMPLETE!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyPrologueComplete => 'PROLOGUE COMPLETE!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifyActComplete(int number) {
|
||||||
|
return 'ACT $number COMPLETE!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewSpell => 'NEW SPELL!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewEquipment => 'NEW EQUIPMENT!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyBossDefeated => 'BOSS DEFEATED!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsTitle => 'RECHARGE ROLLS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsFree => 'Recharge 5 rolls for free?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsAd => 'Watch an ad to recharge 5 rolls?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugTitle => 'DEBUG';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCheatsTitle => 'DEBUG CHEATS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugToolsTitle => 'DEBUG TOOLS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugDeveloperTools => 'DEVELOPER TOOLS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipTask => 'SKIP TASK (L+1)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipTaskDesc => 'Complete task instantly';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipQuest => 'SKIP QUEST (Q!)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipQuestDesc => 'Complete quest instantly';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipAct => 'SKIP ACT (P!)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipActDesc => 'Complete act instantly';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacter => 'CREATE TEST CHARACTER';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterDesc =>
|
||||||
|
'Register Level 100 character to Hall of Fame';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterTitle => 'CREATE TEST CHARACTER?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterMessage =>
|
||||||
|
'Current character will be converted to Level 100\nand registered to the Hall of Fame.\n\n⚠️ Current save file will be deleted.\nThis action cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugTurbo => 'DEBUG: TURBO (20x)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugIapPurchased => 'IAP PURCHASED';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugIapPurchasedDesc => 'ON: Behave as paid user (ads removed)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugOfflineHours => 'OFFLINE HOURS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugOfflineHoursDesc =>
|
||||||
|
'Test return rewards (applies on restart)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugTestCharacterDesc =>
|
||||||
|
'Modify current character to Level 100\nand register to the Hall of Fame.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaTitle => 'LOCAL ARENA';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaSelectFighter => 'SELECT YOUR FIGHTER';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaEmptyTitle => 'Not enough heroes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaEmptyHint => 'Clear the game with 2+ characters';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaSetupTitle => 'ARENA SETUP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaStartBattle => 'START BATTLE';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaBattleTitle => 'ARENA BATTLE';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaMyEquipment => 'MY EQUIPMENT';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaEnemyEquipment => 'ENEMY EQUIPMENT';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaSelected => 'SELECTED';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaRecommended => 'BEST';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaWeaponLocked => 'LOCKED';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaVictory => 'VICTORY!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaDefeat => 'DEFEAT...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaEquipmentExchange => 'EQUIPMENT EXCHANGE';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaTurns => 'TURNS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaWinner => 'WINNER';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaLoser => 'LOSER';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String arenaDefeatedIn(String winner, String loser, int turns) {
|
||||||
|
return '$winner defeated $loser in $turns TURNS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String arenaScoreGain(int score) {
|
||||||
|
return 'You will GAIN +$score';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String arenaScoreLose(int score) {
|
||||||
|
return 'You will LOSE $score';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaEvenTrade => 'Even trade';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaScore => 'SCORE';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsStatistics => 'Statistics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsSession => 'Session';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsAccumulated => 'Total';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsCombat => 'Combat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsPlayTime => 'Play Time';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsMonstersKilled => 'Monsters Killed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsBossesDefeated => 'Bosses Defeated';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsDeaths => 'Deaths';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsDamage => 'Damage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsDamageDealt => 'Damage Dealt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsDamageTaken => 'Damage Taken';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsAverageDps => 'Average DPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsSkills => 'Skills';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsSkillsUsed => 'Skills Used';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsCriticalHits => 'Critical Hits';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsMaxCriticalStreak => 'Max Critical Streak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsCriticalRate => 'Critical Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsEconomy => 'Economy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsGoldEarned => 'Gold Earned';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsGoldSpent => 'Gold Spent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsItemsSold => 'Items Sold';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsPotionsUsed => 'Potions Used';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsProgress => 'Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsLevelUps => 'Level Ups';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsQuestsCompleted => 'Quests Completed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsRecords => 'Records';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsHighestLevel => 'Highest Level';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsHighestGoldHeld => 'Highest Gold Held';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsBestCriticalStreak => 'Best Critical Streak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalPlay => 'Total Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalPlayTime => 'Total Play Time';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsGamesStarted => 'Games Started';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsGamesCompleted => 'Games Completed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsCompletionRate => 'Completion Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalCombat => 'Total Combat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalDeaths => 'Total Deaths';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalLevelUps => 'Total Level Ups';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalDamage => 'Total Damage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalSkills => 'Total Skills';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalEconomy => 'Total Economy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyLevelUpLabel => 'LEVEL UP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyQuestDoneLabel => 'QUEST DONE';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyActClearLabel => 'ACT CLEAR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewSpellLabel => 'NEW SPELL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewItemLabel => 'NEW ITEM';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyBossSlainLabel => 'BOSS SLAIN';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifySavedLabel => 'SAVED';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyInfoLabel => 'INFO';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyWarningLabel => 'WARNING';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,75 +12,75 @@ class L10nJa extends L10n {
|
|||||||
String get appTitle => 'アスキー ネバー ダイ';
|
String get appTitle => 'アスキー ネバー ダイ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tagNoNetwork => 'No network';
|
String get tagNoNetwork => 'オフライン';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tagIdleRpg => 'Idle RPG loop';
|
String get tagIdleRpg => '放置型RPG';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tagLocalSaves => 'Local saves';
|
String get tagLocalSaves => 'ローカル保存';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get newCharacter => 'New character';
|
String get newCharacter => '新規キャラクター';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loadSave => 'Load save';
|
String get loadSave => 'ロード';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loadGame => 'Load Game';
|
String get loadGame => 'ゲームをロード';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get viewBuildPlan => 'View build plan';
|
String get viewBuildPlan => 'ビルド計画を見る';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get buildRoadmap => 'Build roadmap';
|
String get buildRoadmap => 'ビルドロードマップ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get techStack => 'Tech stack';
|
String get techStack => '技術スタック';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cancel => 'Cancel';
|
String get cancel => 'キャンセル';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get exitGame => 'Exit Game';
|
String get exitGame => 'ゲーム終了';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get saveProgressQuestion => 'Save your progress before leaving?';
|
String get saveProgressQuestion => '終了する前にセーブしますか?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get exitWithoutSaving => 'Exit without saving';
|
String get exitWithoutSaving => 'セーブせずに終了';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get saveAndExit => 'Save and Exit';
|
String get saveAndExit => 'セーブして終了';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String progressQuestTitle(String name) {
|
String progressQuestTitle(String name) {
|
||||||
return 'ASCII NEVER DIE - $name';
|
return 'アスキー ネバー ダイ - $name';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get levelUp => 'Level Up';
|
String get levelUp => 'レベルアップ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get completeQuest => 'Complete Quest';
|
String get completeQuest => 'クエスト完了';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get completePlot => 'Complete Plot';
|
String get completePlot => 'プロット完了';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get characterSheet => 'Character Sheet';
|
String get characterSheet => 'キャラクターシート';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get traits => 'Traits';
|
String get traits => '特性';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get stats => 'Stats';
|
String get stats => '能力値';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get experience => 'Experience';
|
String get experience => '経験値';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get xpNeededForNextLevel => 'XP needed for next level';
|
String get xpNeededForNextLevel => '次のレベルまでの必要XP';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get spellBook => 'スキル';
|
String get spellBook => 'スキル';
|
||||||
@@ -89,34 +89,34 @@ class L10nJa extends L10n {
|
|||||||
String get noSpellsYet => '習得したスキルがありません';
|
String get noSpellsYet => '習得したスキルがありません';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipment => 'Equipment';
|
String get equipment => '装備';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get inventory => 'Inventory';
|
String get inventory => 'インベントリ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get encumbrance => 'Encumbrance';
|
String get encumbrance => '積載量';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get combatLog => '戦闘ログ';
|
String get combatLog => '戦闘ログ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get plotDevelopment => 'Plot Development';
|
String get plotDevelopment => 'ストーリー進行';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get quests => 'Quests';
|
String get quests => 'クエスト';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get traitName => 'Name';
|
String get traitName => '名前';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get traitRace => 'Race';
|
String get traitRace => '種族';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get traitClass => 'Class';
|
String get traitClass => '職業';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get traitLevel => 'Level';
|
String get traitLevel => 'レベル';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get statStr => 'STR';
|
String get statStr => 'STR';
|
||||||
@@ -137,43 +137,43 @@ class L10nJa extends L10n {
|
|||||||
String get statCha => 'CHA';
|
String get statCha => 'CHA';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get statHpMax => 'HP Max';
|
String get statHpMax => 'HP最大';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get statMpMax => 'MP Max';
|
String get statMpMax => 'MP最大';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipWeapon => 'Weapon';
|
String get equipWeapon => '武器';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipShield => 'Shield';
|
String get equipShield => '盾';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipHelm => 'Helm';
|
String get equipHelm => '兜';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipHauberk => 'Hauberk';
|
String get equipHauberk => '鎧';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipBrassairts => 'Brassairts';
|
String get equipBrassairts => '肩当て';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipVambraces => 'Vambraces';
|
String get equipVambraces => '腕甲';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipGauntlets => 'Gauntlets';
|
String get equipGauntlets => '篭手';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipGambeson => 'Gambeson';
|
String get equipGambeson => '防護服';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipCuisses => 'Cuisses';
|
String get equipCuisses => '腿当て';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipGreaves => 'Greaves';
|
String get equipGreaves => '脛当て';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get equipSollerets => 'Sollerets';
|
String get equipSollerets => '鉄靴';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get gold => 'コイン';
|
String get gold => 'コイン';
|
||||||
@@ -184,63 +184,63 @@ class L10nJa extends L10n {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get prologue => 'Prologue';
|
String get prologue => 'プロローグ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String actNumber(String number) {
|
String actNumber(String number) {
|
||||||
return 'Act $number';
|
return '第$number幕';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noActiveQuests => 'No active quests';
|
String get noActiveQuests => '進行中のクエストなし';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String questNumber(int number) {
|
String questNumber(int number) {
|
||||||
return 'Quest #$number';
|
return 'クエスト #$number';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get welcomeMessage => 'ASCII NEVER DIEへようこそ!';
|
String get welcomeMessage => 'ASCII NEVER DIEへようこそ!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noSavedGames => 'No saved games found.';
|
String get noSavedGames => 'セーブデータがありません。';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String loadError(String error) {
|
String loadError(String error) {
|
||||||
return 'Failed to load save file: $error';
|
return 'セーブファイルの読み込みに失敗しました: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get name => 'Name';
|
String get name => '名前';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get generateName => 'Generate Name';
|
String get generateName => '名前を生成';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get total => 'Total';
|
String get total => '合計';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get unroll => '元に戻す';
|
String get unroll => '元に戻す';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get roll => 'Roll';
|
String get roll => 'ロール';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get race => 'Race';
|
String get race => '種族';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get classTitle => 'Class';
|
String get classTitle => '職業';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String percentComplete(int percent) {
|
String percentComplete(int percent) {
|
||||||
return '$percent% complete';
|
return '$percent% 完了';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get newCharacterTitle => 'ASCII NEVER DIE - New Character';
|
String get newCharacterTitle => 'アスキー ネバー ダイ - 新規キャラクター';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get soldButton => 'Sold!';
|
String get soldButton => '決定!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get endingCongratulations => '★ おめでとうございます ★';
|
String get endingCongratulations => '★ おめでとうございます ★';
|
||||||
@@ -297,4 +297,384 @@ class L10nJa extends L10n {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get endingHoldToSpeedUp => '長押しで高速スクロール';
|
String get endingHoldToSpeedUp => '長押しで高速スクロール';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get menuTitle => 'メニュー';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsTitle => 'オプション';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get soundTitle => 'サウンド';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get controlSection => '操作';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get infoSection => '情報';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsSection => '設定';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveExitSection => 'セーブ / 終了';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ok => 'OK';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeButton => 'チャージ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get createButton => '作成';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewTitle => 'プレビュー';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nameTitle => '名前';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTitle => '能力値';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get raceTitle => '種族';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get classSection => '職業';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bgmLabel => 'BGM';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sfxLabel => '効果音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hpLabel => 'HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mpLabel => 'MP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get expLabel => 'EXP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyLevelUp => 'レベルアップ!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifyLevel(int level) {
|
||||||
|
return 'レベル $level';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyQuestComplete => 'クエスト完了!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyPrologueComplete => 'プロローグ完了!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifyActComplete(int number) {
|
||||||
|
return '第$number幕 完了!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewSpell => '新しいスキル!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewEquipment => '新しい装備!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyBossDefeated => 'ボス撃破!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsTitle => 'ロール回数チャージ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsFree => '無料で5回チャージしますか?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsAd => '広告を見て5回チャージしますか?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugTitle => 'デバッグ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCheatsTitle => 'デバッグチート';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugToolsTitle => 'デバッグツール';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugDeveloperTools => '開発者ツール';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipTask => 'タスクスキップ (L+1)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipTaskDesc => 'タスクを即時完了';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipQuest => 'クエストスキップ (Q!)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipQuestDesc => 'クエストを即時完了';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipAct => 'アクトスキップ (P!)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipActDesc => 'アクトを即時完了';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacter => 'テストキャラクター作成';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterDesc => 'レベル100キャラクターを殿堂に登録';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterTitle => 'テストキャラクターを作成しますか?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterMessage =>
|
||||||
|
'現在のキャラクターがレベル100に変換され\n殿堂に登録されます。\n\n⚠️ 現在のセーブファイルは削除されます。\nこの操作は元に戻せません。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugTurbo => 'デバッグ: ターボ (20x)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugIapPurchased => 'IAP購入済み';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugIapPurchasedDesc => 'ON: 有料ユーザーとして動作(広告非表示)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugOfflineHours => 'オフライン時間';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugOfflineHoursDesc => '復帰報酬テスト(再起動時に適用)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugTestCharacterDesc => '現在のキャラクターをレベル100に変更して\n殿堂に登録します。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaTitle => 'ローカルアリーナ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaSelectFighter => 'ファイターを選択';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaEmptyTitle => 'ヒーローが不足しています';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaEmptyHint => '2人以上のキャラでクリアしてください';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaSetupTitle => 'アリーナ設定';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaStartBattle => 'バトル開始';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaBattleTitle => 'アリーナバトル';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaMyEquipment => '自分の装備';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaEnemyEquipment => '敵の装備';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaSelected => '選択済み';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaRecommended => 'おすすめ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaWeaponLocked => 'ロック';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaVictory => '勝利!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaDefeat => '敗北...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaEquipmentExchange => '装備交換';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaTurns => 'ターン';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaWinner => '勝者';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaLoser => '敗者';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String arenaDefeatedIn(String winner, String loser, int turns) {
|
||||||
|
return '$winnerが$loserを$turnsターンで撃破';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String arenaScoreGain(int score) {
|
||||||
|
return '+$score獲得予定';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String arenaScoreLose(int score) {
|
||||||
|
return '$score損失予定';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaEvenTrade => '等価交換';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaScore => 'スコア';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsStatistics => '統計';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsSession => 'セッション';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsAccumulated => '累積';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsCombat => '戦闘';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsPlayTime => 'プレイ時間';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsMonstersKilled => '倒したモンスター';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsBossesDefeated => 'ボス討伐';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsDeaths => '死亡回数';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsDamage => 'ダメージ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsDamageDealt => '与えたダメージ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsDamageTaken => '受けたダメージ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsAverageDps => '平均DPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsSkills => 'スキル';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsSkillsUsed => 'スキル使用';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsCriticalHits => 'クリティカルヒット';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsMaxCriticalStreak => '最大連続クリティカル';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsCriticalRate => 'クリティカル率';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsEconomy => '経済';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsGoldEarned => '獲得ゴールド';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsGoldSpent => '消費ゴールド';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsItemsSold => '売却アイテム';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsPotionsUsed => 'ポーション使用';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsProgress => '進行';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsLevelUps => 'レベルアップ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsQuestsCompleted => '完了したクエスト';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsRecords => '記録';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsHighestLevel => '最高レベル';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsHighestGoldHeld => '最大所持ゴールド';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsBestCriticalStreak => '最高連続クリティカル';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalPlay => '総プレイ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalPlayTime => '総プレイ時間';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsGamesStarted => '開始したゲーム';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsGamesCompleted => 'クリアしたゲーム';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsCompletionRate => 'クリア率';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalCombat => '総戦闘';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalDeaths => '総死亡';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalLevelUps => '総レベルアップ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalDamage => '総ダメージ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalSkills => '総スキル';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalEconomy => '総経済';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyLevelUpLabel => 'レベルアップ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyQuestDoneLabel => 'クエスト完了';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyActClearLabel => '幕完了';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewSpellLabel => '新しいスキル';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewItemLabel => '新しいアイテム';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyBossSlainLabel => 'ボス撃破';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifySavedLabel => 'セーブ済み';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyInfoLabel => '情報';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyWarningLabel => '警告';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,4 +297,384 @@ class L10nKo extends L10n {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get endingHoldToSpeedUp => '길게 누르면 빨리 스크롤';
|
String get endingHoldToSpeedUp => '길게 누르면 빨리 스크롤';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get menuTitle => '메뉴';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsTitle => '옵션';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get soundTitle => '사운드';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get controlSection => '제어';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get infoSection => '정보';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsSection => '설정';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveExitSection => '저장 / 종료';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ok => '확인';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeButton => '충전';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get createButton => '생성';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewTitle => '미리보기';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nameTitle => '이름';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTitle => '능력치';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get raceTitle => '종족';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get classSection => '직업';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bgmLabel => 'BGM';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sfxLabel => '효과음';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hpLabel => 'HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mpLabel => 'MP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get expLabel => '경험치';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyLevelUp => '레벨 업!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifyLevel(int level) {
|
||||||
|
return '레벨 $level';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyQuestComplete => '퀘스트 완료!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyPrologueComplete => '프롤로그 완료!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifyActComplete(int number) {
|
||||||
|
return '$number막 완료!';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewSpell => '새 주문!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewEquipment => '새 장비!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyBossDefeated => '보스 처치!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsTitle => '굴리기 충전';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsFree => '무료로 5회 충전하시겠습니까?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rechargeRollsAd => '광고를 보고 5회 충전하시겠습니까?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugTitle => '디버그';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCheatsTitle => '디버그 치트';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugToolsTitle => '디버그 도구';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugDeveloperTools => '개발자 도구';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipTask => '태스크 건너뛰기 (L+1)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipTaskDesc => '태스크 즉시 완료';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipQuest => '퀘스트 건너뛰기 (Q!)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipQuestDesc => '퀘스트 즉시 완료';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipAct => '액트 건너뛰기 (P!)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugSkipActDesc => '액트 즉시 완료';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacter => '테스트 캐릭터 생성';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterDesc => '레벨 100 캐릭터를 명예의 전당에 등록';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterTitle => '테스트 캐릭터 생성?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugCreateTestCharacterMessage =>
|
||||||
|
'현재 캐릭터가 레벨 100으로 변환되어\n명예의 전당에 등록됩니다.\n\n⚠️ 현재 세이브 파일이 삭제됩니다.\n이 작업은 되돌릴 수 없습니다.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugTurbo => '디버그: 터보 (20x)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugIapPurchased => 'IAP 구매됨';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugIapPurchasedDesc => 'ON: 유료 유저로 동작 (광고 제거)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugOfflineHours => '오프라인 시간';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugOfflineHoursDesc => '복귀 보상 테스트 (재시작 시 적용)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get debugTestCharacterDesc => '현재 캐릭터를 레벨 100으로 수정하여\n명예의 전당에 등록합니다.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaTitle => '로컬 아레나';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaSelectFighter => '전사를 선택하세요';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaEmptyTitle => '영웅이 부족합니다';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaEmptyHint => '2명 이상 캐릭터로 클리어하세요';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaSetupTitle => '아레나 설정';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaStartBattle => '전투 시작';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaBattleTitle => '아레나 전투';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaMyEquipment => '내 장비';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaEnemyEquipment => '상대 장비';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaSelected => '선택됨';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaRecommended => '추천';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaWeaponLocked => '잠김';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaVictory => '승리!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaDefeat => '패배...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaEquipmentExchange => '장비 교환';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaTurns => '턴';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaWinner => '승자';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaLoser => '패자';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String arenaDefeatedIn(String winner, String loser, int turns) {
|
||||||
|
return '$winner이(가) $loser을(를) $turns턴 만에 격파';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String arenaScoreGain(int score) {
|
||||||
|
return '+$score 획득 예정';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String arenaScoreLose(int score) {
|
||||||
|
return '$score 손실 예정';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaEvenTrade => '등가 교환';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get arenaScore => '점수';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsStatistics => '통계';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsSession => '세션';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsAccumulated => '누적';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsCombat => '전투';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsPlayTime => '플레이 시간';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsMonstersKilled => '처치한 몬스터';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsBossesDefeated => '보스 처치';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsDeaths => '사망 횟수';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsDamage => '데미지';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsDamageDealt => '입힌 데미지';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsDamageTaken => '받은 데미지';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsAverageDps => '평균 DPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsSkills => '스킬';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsSkillsUsed => '스킬 사용';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsCriticalHits => '크리티컬 히트';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsMaxCriticalStreak => '최대 연속 크리티컬';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsCriticalRate => '크리티컬 비율';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsEconomy => '경제';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsGoldEarned => '획득 골드';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsGoldSpent => '소비 골드';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsItemsSold => '판매 아이템';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsPotionsUsed => '물약 사용';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsProgress => '진행';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsLevelUps => '레벨업';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsQuestsCompleted => '완료한 퀘스트';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsRecords => '기록';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsHighestLevel => '최고 레벨';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsHighestGoldHeld => '최대 보유 골드';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsBestCriticalStreak => '최고 연속 크리티컬';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalPlay => '총 플레이';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalPlayTime => '총 플레이 시간';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsGamesStarted => '시작한 게임';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsGamesCompleted => '클리어한 게임';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsCompletionRate => '클리어율';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalCombat => '총 전투';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalDeaths => '총 사망';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalLevelUps => '총 레벨업';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalDamage => '총 데미지';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalSkills => '총 스킬';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get statsTotalEconomy => '총 경제';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyLevelUpLabel => '레벨 업';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyQuestDoneLabel => '퀘스트 완료';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyActClearLabel => '막 완료';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewSpellLabel => '새 주문';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyNewItemLabel => '새 아이템';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyBossSlainLabel => '보스 처치';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifySavedLabel => '저장됨';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyInfoLabel => '정보';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifyWarningLabel => '경고';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,300 +0,0 @@
|
|||||||
// ignore: unused_import
|
|
||||||
import 'package:intl/intl.dart' as intl;
|
|
||||||
import 'app_localizations.dart';
|
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
|
||||||
|
|
||||||
/// The translations for Chinese (`zh`).
|
|
||||||
class L10nZh extends L10n {
|
|
||||||
L10nZh([String locale = 'zh']) : super(locale);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get appTitle => 'ASCII NEVER DIE';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get tagNoNetwork => 'No network';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get tagIdleRpg => 'Idle RPG loop';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get tagLocalSaves => 'Local saves';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get newCharacter => 'New character';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get loadSave => 'Load save';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get loadGame => 'Load Game';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get viewBuildPlan => 'View build plan';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get buildRoadmap => 'Build roadmap';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get techStack => 'Tech stack';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get cancel => 'Cancel';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get exitGame => 'Exit Game';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get saveProgressQuestion => 'Save your progress before leaving?';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get exitWithoutSaving => 'Exit without saving';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get saveAndExit => 'Save and Exit';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String progressQuestTitle(String name) {
|
|
||||||
return 'ASCII NEVER DIE - $name';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get levelUp => 'Level Up';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get completeQuest => 'Complete Quest';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get completePlot => 'Complete Plot';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get characterSheet => 'Character Sheet';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get traits => 'Traits';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get stats => 'Stats';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get experience => 'Experience';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get xpNeededForNextLevel => 'XP needed for next level';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get spellBook => '技能';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get noSpellsYet => '暂无技能';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipment => 'Equipment';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get inventory => 'Inventory';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get encumbrance => 'Encumbrance';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get combatLog => '战斗日志';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get plotDevelopment => 'Plot Development';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get quests => 'Quests';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get traitName => 'Name';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get traitRace => 'Race';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get traitClass => 'Class';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get traitLevel => 'Level';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statStr => 'STR';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statCon => 'CON';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statDex => 'DEX';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statInt => 'INT';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statWis => 'WIS';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statCha => 'CHA';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statHpMax => 'HP Max';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get statMpMax => 'MP Max';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipWeapon => 'Weapon';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipShield => 'Shield';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipHelm => 'Helm';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipHauberk => 'Hauberk';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipBrassairts => 'Brassairts';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipVambraces => 'Vambraces';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipGauntlets => 'Gauntlets';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipGambeson => 'Gambeson';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipCuisses => 'Cuisses';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipGreaves => 'Greaves';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get equipSollerets => 'Sollerets';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get gold => 'Gold';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String goldAmount(int amount) {
|
|
||||||
return 'Gold: $amount';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get prologue => 'Prologue';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String actNumber(String number) {
|
|
||||||
return 'Act $number';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get noActiveQuests => 'No active quests';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String questNumber(int number) {
|
|
||||||
return 'Quest #$number';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get welcomeMessage => '欢迎来到ASCII NEVER DIE!';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get noSavedGames => 'No saved games found.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String loadError(String error) {
|
|
||||||
return 'Failed to load save file: $error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get name => 'Name';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get generateName => 'Generate Name';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get total => 'Total';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get unroll => '撤销';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get roll => 'Roll';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get race => 'Race';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get classTitle => 'Class';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String percentComplete(int percent) {
|
|
||||||
return '$percent% complete';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get newCharacterTitle => 'ASCII NEVER DIE - New Character';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get soldButton => 'Sold!';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingCongratulations => '★ 恭喜通关 ★';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingGameComplete => '您已完成游戏!';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingTheHero => '英雄';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String endingLevelFormat(int level) {
|
|
||||||
return '等级 $level';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingJourneyStats => '冒险记录';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingMonstersSlain => '击败的怪物';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingQuestsCompleted => '完成的任务';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingPlayTime => '游戏时间';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingFinalStats => '最终属性';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingCredits => '制作人员';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingThankYou => '感谢您的游玩!';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingLegendLivesOn => '您的传奇将永远流传...';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingHallOfFameLine1 => '您的英雄事迹';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingHallOfFameLine2 => '将被铭记于荣誉殿堂';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingHallOfFameButton => '荣誉殿堂';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingSkip => '跳过';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingTapToSkip => '点击跳过';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get endingHoldToSpeedUp => '长按加速滚动';
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
{
|
|
||||||
"@@locale": "zh",
|
|
||||||
|
|
||||||
"appTitle": "ASCII NEVER DIE",
|
|
||||||
"tagNoNetwork": "No network",
|
|
||||||
"tagIdleRpg": "Idle RPG loop",
|
|
||||||
"tagLocalSaves": "Local saves",
|
|
||||||
"newCharacter": "New character",
|
|
||||||
"loadSave": "Load save",
|
|
||||||
"loadGame": "Load Game",
|
|
||||||
"viewBuildPlan": "View build plan",
|
|
||||||
"buildRoadmap": "Build roadmap",
|
|
||||||
"techStack": "Tech stack",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"exitGame": "Exit Game",
|
|
||||||
"saveProgressQuestion": "Save your progress before leaving?",
|
|
||||||
"exitWithoutSaving": "Exit without saving",
|
|
||||||
"saveAndExit": "Save and Exit",
|
|
||||||
"progressQuestTitle": "ASCII NEVER DIE - {name}",
|
|
||||||
"levelUp": "Level Up",
|
|
||||||
"completeQuest": "Complete Quest",
|
|
||||||
"completePlot": "Complete Plot",
|
|
||||||
"characterSheet": "Character Sheet",
|
|
||||||
"traits": "Traits",
|
|
||||||
"stats": "Stats",
|
|
||||||
"experience": "Experience",
|
|
||||||
"xpNeededForNextLevel": "XP needed for next level",
|
|
||||||
"spellBook": "技能",
|
|
||||||
"noSpellsYet": "暂无技能",
|
|
||||||
"equipment": "Equipment",
|
|
||||||
"inventory": "Inventory",
|
|
||||||
"encumbrance": "Encumbrance",
|
|
||||||
"combatLog": "战斗日志",
|
|
||||||
"plotDevelopment": "Plot Development",
|
|
||||||
"quests": "Quests",
|
|
||||||
"traitName": "Name",
|
|
||||||
"traitRace": "Race",
|
|
||||||
"traitClass": "Class",
|
|
||||||
"traitLevel": "Level",
|
|
||||||
"statStr": "STR",
|
|
||||||
"statCon": "CON",
|
|
||||||
"statDex": "DEX",
|
|
||||||
"statInt": "INT",
|
|
||||||
"statWis": "WIS",
|
|
||||||
"statCha": "CHA",
|
|
||||||
"statHpMax": "HP Max",
|
|
||||||
"statMpMax": "MP Max",
|
|
||||||
"equipWeapon": "Weapon",
|
|
||||||
"equipShield": "Shield",
|
|
||||||
"equipHelm": "Helm",
|
|
||||||
"equipHauberk": "Hauberk",
|
|
||||||
"equipBrassairts": "Brassairts",
|
|
||||||
"equipVambraces": "Vambraces",
|
|
||||||
"equipGauntlets": "Gauntlets",
|
|
||||||
"equipGambeson": "Gambeson",
|
|
||||||
"equipCuisses": "Cuisses",
|
|
||||||
"equipGreaves": "Greaves",
|
|
||||||
"equipSollerets": "Sollerets",
|
|
||||||
"gold": "Gold",
|
|
||||||
"goldAmount": "Gold: {amount}",
|
|
||||||
"prologue": "Prologue",
|
|
||||||
"actNumber": "Act {number}",
|
|
||||||
"noActiveQuests": "No active quests",
|
|
||||||
"questNumber": "Quest #{number}",
|
|
||||||
"welcomeMessage": "欢迎来到ASCII NEVER DIE!",
|
|
||||||
"noSavedGames": "No saved games found.",
|
|
||||||
"loadError": "Failed to load save file: {error}",
|
|
||||||
"name": "Name",
|
|
||||||
"generateName": "Generate Name",
|
|
||||||
"total": "Total",
|
|
||||||
"unroll": "撤销",
|
|
||||||
"roll": "Roll",
|
|
||||||
"race": "Race",
|
|
||||||
"classTitle": "Class",
|
|
||||||
"percentComplete": "{percent}% complete",
|
|
||||||
"newCharacterTitle": "ASCII NEVER DIE - New Character",
|
|
||||||
"soldButton": "Sold!",
|
|
||||||
|
|
||||||
"endingCongratulations": "★ 恭喜通关 ★",
|
|
||||||
"endingGameComplete": "您已完成游戏!",
|
|
||||||
"endingTheHero": "英雄",
|
|
||||||
"endingLevelFormat": "等级 {level}",
|
|
||||||
"endingJourneyStats": "冒险记录",
|
|
||||||
"endingMonstersSlain": "击败的怪物",
|
|
||||||
"endingQuestsCompleted": "完成的任务",
|
|
||||||
"endingPlayTime": "游戏时间",
|
|
||||||
"endingFinalStats": "最终属性",
|
|
||||||
"endingCredits": "制作人员",
|
|
||||||
"endingThankYou": "感谢您的游玩!",
|
|
||||||
"endingLegendLivesOn": "您的传奇将永远流传...",
|
|
||||||
"endingHallOfFameLine1": "您的英雄事迹",
|
|
||||||
"endingHallOfFameLine2": "将被铭记于荣誉殿堂",
|
|
||||||
"endingHallOfFameButton": "荣誉殿堂",
|
|
||||||
"endingSkip": "跳过",
|
|
||||||
"endingTapToSkip": "点击跳过",
|
|
||||||
"endingHoldToSpeedUp": "长按加速滚动"
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import 'package:asciineverdie/src/app.dart';
|
import 'package:asciineverdie/src/app.dart';
|
||||||
|
import 'package:asciineverdie/src/core/di/service_locator.dart';
|
||||||
|
import 'package:asciineverdie/src/core/logging/error_logger_zone.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const AskiiNeverDieApp());
|
setupErrorHandling(() async {
|
||||||
|
// 서비스 로케이터(service locator) 초기화
|
||||||
|
setupServiceLocator();
|
||||||
|
runApp(const AskiiNeverDieApp());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
276
lib/src/app.dart
276
lib/src/app.dart
@@ -3,10 +3,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||||
import 'package:asciineverdie/src/core/audio/audio_service.dart';
|
import 'package:asciineverdie/src/core/audio/audio_service.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/ad_service.dart';
|
import 'package:asciineverdie/src/core/infrastructure/ad_service.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/debug_settings_service.dart';
|
import 'package:asciineverdie/src/core/engine/debug_settings_service.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
import 'package:asciineverdie/src/core/infrastructure/iap_service.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
import 'package:asciineverdie/src/app_theme.dart';
|
||||||
|
import 'package:asciineverdie/src/splash_screen.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
||||||
@@ -221,140 +222,6 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 앱 테마 (Dark Fantasy 스타일)
|
|
||||||
ThemeData get _theme => ThemeData(
|
|
||||||
colorScheme: RetroColors.darkColorScheme,
|
|
||||||
scaffoldBackgroundColor: RetroColors.deepBrown,
|
|
||||||
useMaterial3: true,
|
|
||||||
// 카드/다이얼로그 레트로 배경
|
|
||||||
cardColor: RetroColors.darkBrown,
|
|
||||||
dialogTheme: const DialogThemeData(
|
|
||||||
backgroundColor: Color(0xFF24283B),
|
|
||||||
titleTextStyle: TextStyle(
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 15,
|
|
||||||
color: Color(0xFFE0AF68),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 앱바 레트로 스타일
|
|
||||||
appBarTheme: const AppBarTheme(
|
|
||||||
backgroundColor: Color(0xFF24283B),
|
|
||||||
foregroundColor: Color(0xFFC0CAF5),
|
|
||||||
titleTextStyle: TextStyle(
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 15,
|
|
||||||
color: Color(0xFFE0AF68),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 버튼 테마 (inherit: false로 애니메이션 lerp 오류 방지)
|
|
||||||
filledButtonTheme: FilledButtonThemeData(
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: const Color(0xFF3D4260),
|
|
||||||
foregroundColor: const Color(0xFFC0CAF5),
|
|
||||||
textStyle: const TextStyle(
|
|
||||||
inherit: false,
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 14,
|
|
||||||
color: Color(0xFFC0CAF5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: const Color(0xFFE0AF68),
|
|
||||||
side: const BorderSide(color: Color(0xFFE0AF68), width: 2),
|
|
||||||
textStyle: const TextStyle(
|
|
||||||
inherit: false,
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 14,
|
|
||||||
color: Color(0xFFE0AF68),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
textButtonTheme: TextButtonThemeData(
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: const Color(0xFFC0CAF5),
|
|
||||||
textStyle: const TextStyle(
|
|
||||||
inherit: false,
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 14,
|
|
||||||
color: Color(0xFFC0CAF5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 텍스트 테마
|
|
||||||
textTheme: const TextTheme(
|
|
||||||
headlineLarge: TextStyle(
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 20,
|
|
||||||
color: Color(0xFFE0AF68),
|
|
||||||
),
|
|
||||||
headlineMedium: TextStyle(
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 16,
|
|
||||||
color: Color(0xFFE0AF68),
|
|
||||||
),
|
|
||||||
headlineSmall: TextStyle(
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 15,
|
|
||||||
color: Color(0xFFE0AF68),
|
|
||||||
),
|
|
||||||
titleLarge: TextStyle(
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 15,
|
|
||||||
color: Color(0xFFC0CAF5),
|
|
||||||
),
|
|
||||||
titleMedium: TextStyle(
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 14,
|
|
||||||
color: Color(0xFFC0CAF5),
|
|
||||||
),
|
|
||||||
titleSmall: TextStyle(
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 14,
|
|
||||||
color: Color(0xFFC0CAF5),
|
|
||||||
),
|
|
||||||
bodyLarge: TextStyle(fontSize: 18, color: Color(0xFFC0CAF5)),
|
|
||||||
bodyMedium: TextStyle(fontSize: 17, color: Color(0xFFC0CAF5)),
|
|
||||||
bodySmall: TextStyle(fontSize: 15, color: Color(0xFFC0CAF5)),
|
|
||||||
labelLarge: TextStyle(
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 14,
|
|
||||||
color: Color(0xFFC0CAF5),
|
|
||||||
),
|
|
||||||
labelMedium: TextStyle(
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 14,
|
|
||||||
color: Color(0xFFC0CAF5),
|
|
||||||
),
|
|
||||||
labelSmall: TextStyle(
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 13,
|
|
||||||
color: Color(0xFFC0CAF5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 칩 테마
|
|
||||||
chipTheme: const ChipThemeData(
|
|
||||||
backgroundColor: Color(0xFF2A2E3F),
|
|
||||||
labelStyle: TextStyle(
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 14,
|
|
||||||
color: Color(0xFFC0CAF5),
|
|
||||||
),
|
|
||||||
side: BorderSide(color: Color(0xFF545C7E)),
|
|
||||||
),
|
|
||||||
// 리스트 타일 테마
|
|
||||||
listTileTheme: const ListTileThemeData(
|
|
||||||
textColor: Color(0xFFC0CAF5),
|
|
||||||
iconColor: Color(0xFFE0AF68),
|
|
||||||
),
|
|
||||||
// 프로그레스 인디케이터
|
|
||||||
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
|
||||||
color: Color(0xFFE0AF68),
|
|
||||||
linearTrackColor: Color(0xFF3B4261),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
@@ -363,7 +230,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp>
|
|||||||
localizationsDelegates: L10n.localizationsDelegates,
|
localizationsDelegates: L10n.localizationsDelegates,
|
||||||
supportedLocales: L10n.supportedLocales,
|
supportedLocales: L10n.supportedLocales,
|
||||||
locale: _locale, // 사용자 선택 로케일 (null이면 시스템 기본값)
|
locale: _locale, // 사용자 선택 로케일 (null이면 시스템 기본값)
|
||||||
theme: _theme,
|
theme: buildAppTheme(),
|
||||||
navigatorObservers: [_routeObserver],
|
navigatorObservers: [_routeObserver],
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
|
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
|
||||||
@@ -382,7 +249,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp>
|
|||||||
Widget _buildHomeScreen() {
|
Widget _buildHomeScreen() {
|
||||||
// 세이브 확인 중이면 로딩 스플래시 표시
|
// 세이브 확인 중이면 로딩 스플래시 표시
|
||||||
if (_isCheckingSave) {
|
if (_isCheckingSave) {
|
||||||
return const _SplashScreen();
|
return const SplashScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
return FrontScreen(
|
return FrontScreen(
|
||||||
@@ -590,134 +457,3 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 스플래시 화면 (세이브 파일 확인 중) - 레트로 스타일
|
|
||||||
class _SplashScreen extends StatelessWidget {
|
|
||||||
const _SplashScreen();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: RetroColors.deepBrown,
|
|
||||||
body: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
// 타이틀 로고
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: RetroColors.panelBg,
|
|
||||||
border: Border.all(color: RetroColors.gold, width: 3),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// 아이콘
|
|
||||||
const Icon(
|
|
||||||
Icons.auto_awesome,
|
|
||||||
size: 32,
|
|
||||||
color: RetroColors.gold,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
// 타이틀
|
|
||||||
const Text(
|
|
||||||
'ASCII',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 22,
|
|
||||||
color: RetroColors.gold,
|
|
||||||
shadows: [
|
|
||||||
Shadow(
|
|
||||||
color: RetroColors.goldDark,
|
|
||||||
offset: Offset(2, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
const Text(
|
|
||||||
'NEVER DIE',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'PressStart2P',
|
|
||||||
fontSize: 16,
|
|
||||||
color: RetroColors.cream,
|
|
||||||
shadows: [
|
|
||||||
Shadow(color: RetroColors.brown, offset: Offset(1, 1)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
// 레트로 로딩 바
|
|
||||||
SizedBox(width: 160, child: _RetroLoadingBar()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 레트로 스타일 로딩 바 (애니메이션)
|
|
||||||
class _RetroLoadingBar extends StatefulWidget {
|
|
||||||
@override
|
|
||||||
State<_RetroLoadingBar> createState() => _RetroLoadingBarState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RetroLoadingBarState extends State<_RetroLoadingBar>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late AnimationController _controller;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = AnimationController(
|
|
||||||
duration: const Duration(milliseconds: 1500),
|
|
||||||
vsync: this,
|
|
||||||
)..repeat();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
const segmentCount = 10;
|
|
||||||
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: _controller,
|
|
||||||
builder: (context, child) {
|
|
||||||
// 웨이브 효과: 각 세그먼트가 순차적으로 켜지고 꺼짐
|
|
||||||
return Container(
|
|
||||||
height: 16,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: RetroColors.panelBg,
|
|
||||||
border: Border.all(color: RetroColors.panelBorderOuter, width: 2),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: List.generate(segmentCount, (index) {
|
|
||||||
// 웨이브 패턴 계산
|
|
||||||
final progress = _controller.value * segmentCount;
|
|
||||||
final distance = (index - progress).abs();
|
|
||||||
final isLit = distance < 2 || (segmentCount - distance) < 2;
|
|
||||||
final opacity = isLit ? 1.0 : 0.2;
|
|
||||||
|
|
||||||
return Expanded(
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.all(1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: RetroColors.gold.withValues(alpha: opacity),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
137
lib/src/app_theme.dart
Normal file
137
lib/src/app_theme.dart
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|
||||||
|
/// 앱 테마 (Dark Fantasy 스타일)
|
||||||
|
ThemeData buildAppTheme() => ThemeData(
|
||||||
|
colorScheme: RetroColors.darkColorScheme,
|
||||||
|
scaffoldBackgroundColor: RetroColors.deepBrown,
|
||||||
|
useMaterial3: true,
|
||||||
|
// 카드/다이얼로그 레트로 배경
|
||||||
|
cardColor: RetroColors.darkBrown,
|
||||||
|
dialogTheme: const DialogThemeData(
|
||||||
|
backgroundColor: Color(0xFF24283B),
|
||||||
|
titleTextStyle: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 15,
|
||||||
|
color: Color(0xFFE0AF68),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 앱바 레트로 스타일
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: Color(0xFF24283B),
|
||||||
|
foregroundColor: Color(0xFFC0CAF5),
|
||||||
|
titleTextStyle: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 15,
|
||||||
|
color: Color(0xFFE0AF68),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 버튼 테마 (inherit: false로 애니메이션 lerp 오류 방지)
|
||||||
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF3D4260),
|
||||||
|
foregroundColor: const Color(0xFFC0CAF5),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
inherit: false,
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFFE0AF68),
|
||||||
|
side: const BorderSide(color: Color(0xFFE0AF68), width: 2),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
inherit: false,
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFE0AF68),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textButtonTheme: TextButtonThemeData(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFFC0CAF5),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
inherit: false,
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 텍스트 테마
|
||||||
|
textTheme: const TextTheme(
|
||||||
|
headlineLarge: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 20,
|
||||||
|
color: Color(0xFFE0AF68),
|
||||||
|
),
|
||||||
|
headlineMedium: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 16,
|
||||||
|
color: Color(0xFFE0AF68),
|
||||||
|
),
|
||||||
|
headlineSmall: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 15,
|
||||||
|
color: Color(0xFFE0AF68),
|
||||||
|
),
|
||||||
|
titleLarge: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 15,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
titleMedium: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
titleSmall: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
bodyLarge: TextStyle(fontSize: 18, color: Color(0xFFC0CAF5)),
|
||||||
|
bodyMedium: TextStyle(fontSize: 17, color: Color(0xFFC0CAF5)),
|
||||||
|
bodySmall: TextStyle(fontSize: 15, color: Color(0xFFC0CAF5)),
|
||||||
|
labelLarge: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
labelMedium: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
labelSmall: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 13,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 칩 테마
|
||||||
|
chipTheme: const ChipThemeData(
|
||||||
|
backgroundColor: Color(0xFF2A2E3F),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFFC0CAF5),
|
||||||
|
),
|
||||||
|
side: BorderSide(color: Color(0xFF545C7E)),
|
||||||
|
),
|
||||||
|
// 리스트 타일 테마
|
||||||
|
listTileTheme: const ListTileThemeData(
|
||||||
|
textColor: Color(0xFFC0CAF5),
|
||||||
|
iconColor: Color(0xFFE0AF68),
|
||||||
|
),
|
||||||
|
// 프로그레스 인디케이터
|
||||||
|
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
||||||
|
color: Color(0xFFE0AF68),
|
||||||
|
linearTrackColor: Color(0xFF3B4261),
|
||||||
|
),
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -364,6 +364,9 @@ class AudioService {
|
|||||||
try {
|
try {
|
||||||
await _staticBgmPlayer?.stop();
|
await _staticBgmPlayer?.stop();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
// SFX도 즉시 정지
|
||||||
|
await _playerSfxPool?.stopAll();
|
||||||
|
await _monsterSfxPool?.stopAll();
|
||||||
_currentBgm = null;
|
_currentBgm = null;
|
||||||
debugPrint('[AudioService] All audio paused (was playing: $_pausedBgm)');
|
debugPrint('[AudioService] All audio paused (was playing: $_pausedBgm)');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,22 @@ class SfxChannelPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 모든 SFX 즉시 정지 및 대기열 비우기
|
||||||
|
Future<void> stopAll() async {
|
||||||
|
_pendingQueue.clear();
|
||||||
|
final players = _staticPlayers[name];
|
||||||
|
final busy = _staticBusy[name];
|
||||||
|
if (players == null) return;
|
||||||
|
for (var i = 0; i < players.length; i++) {
|
||||||
|
try {
|
||||||
|
await players[i].stop();
|
||||||
|
} catch (_) {}
|
||||||
|
if (busy != null && i < busy.length) {
|
||||||
|
busy[i] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 리소스 해제
|
/// 리소스 해제
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
final players = _staticPlayers[name];
|
final players = _staticPlayers[name];
|
||||||
|
|||||||
31
lib/src/core/di/i_ad_service.dart
Normal file
31
lib/src/core/di/i_ad_service.dart
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:asciineverdie/src/core/infrastructure/ad_service.dart';
|
||||||
|
|
||||||
|
/// 광고 서비스 인터페이스 (interface)
|
||||||
|
///
|
||||||
|
/// 광고 표시 기능의 추상화 계층.
|
||||||
|
/// 테스트 시 목(mock) 구현체로 교체 가능.
|
||||||
|
abstract class IAdService {
|
||||||
|
/// AdMob SDK 초기화
|
||||||
|
Future<void> initialize();
|
||||||
|
|
||||||
|
/// 리워드 광고(rewarded ad) 준비 여부
|
||||||
|
bool get isRewardedAdReady;
|
||||||
|
|
||||||
|
/// 인터스티셜 광고(interstitial ad) 준비 여부
|
||||||
|
bool get isInterstitialAdReady;
|
||||||
|
|
||||||
|
/// 리워드 광고 표시
|
||||||
|
Future<AdResult> showRewardedAd({
|
||||||
|
required AdType adType,
|
||||||
|
required void Function() onRewarded,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 인터스티셜 광고 표시
|
||||||
|
Future<AdResult> showInterstitialAd({
|
||||||
|
required AdType adType,
|
||||||
|
required void Function() onComplete,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 리소스 해제
|
||||||
|
void dispose();
|
||||||
|
}
|
||||||
34
lib/src/core/di/i_iap_service.dart
Normal file
34
lib/src/core/di/i_iap_service.dart
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:asciineverdie/src/core/infrastructure/iap_service.dart';
|
||||||
|
|
||||||
|
/// IAP 서비스 인터페이스 (interface)
|
||||||
|
///
|
||||||
|
/// 인앱 구매 기능의 추상화 계층.
|
||||||
|
/// 테스트 시 목(mock) 구현체로 교체 가능.
|
||||||
|
abstract class IIAPService {
|
||||||
|
/// IAP 서비스 초기화
|
||||||
|
Future<void> initialize();
|
||||||
|
|
||||||
|
/// 광고 제거 구매 여부
|
||||||
|
bool get isAdRemovalPurchased;
|
||||||
|
|
||||||
|
/// 스토어 가용성(store availability)
|
||||||
|
bool get isStoreAvailable;
|
||||||
|
|
||||||
|
/// 광고 제거 상품 가격 문자열
|
||||||
|
String get removeAdsPrice;
|
||||||
|
|
||||||
|
/// 광고 제거 구매
|
||||||
|
Future<IAPResult> purchaseRemoveAds();
|
||||||
|
|
||||||
|
/// 구매 복원(restore purchases)
|
||||||
|
Future<IAPResult> restorePurchases();
|
||||||
|
|
||||||
|
/// 디버그 모드 IAP 시뮬레이션(debug simulation) 활성화 여부
|
||||||
|
bool get debugIAPSimulated;
|
||||||
|
|
||||||
|
/// 디버그 모드 IAP 시뮬레이션 토글
|
||||||
|
set debugIAPSimulated(bool value);
|
||||||
|
|
||||||
|
/// 리소스 해제
|
||||||
|
void dispose();
|
||||||
|
}
|
||||||
25
lib/src/core/di/service_locator.dart
Normal file
25
lib/src/core/di/service_locator.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/src/core/di/i_ad_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/di/i_iap_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/infrastructure/ad_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/infrastructure/iap_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/logging/error_logger.dart';
|
||||||
|
|
||||||
|
/// 전역 서비스 로케이터(service locator) 인스턴스
|
||||||
|
final GetIt sl = GetIt.instance;
|
||||||
|
|
||||||
|
/// 서비스 로케이터 초기화
|
||||||
|
///
|
||||||
|
/// 앱 시작 시 한 번 호출하여 모든 서비스를 등록합니다.
|
||||||
|
/// 점진적 도입: IAPService, AdService만 먼저 등록.
|
||||||
|
void setupServiceLocator() {
|
||||||
|
// 에러 로거(error logger) — 이미 초기화된 싱글톤 등록
|
||||||
|
sl.registerSingleton<ErrorLogger>(ErrorLogger.instance);
|
||||||
|
|
||||||
|
// IAP 서비스 (싱글톤 등록)
|
||||||
|
sl.registerLazySingleton<IIAPService>(() => IAPService.createInstance());
|
||||||
|
|
||||||
|
// 광고 서비스 (싱글톤 등록)
|
||||||
|
sl.registerLazySingleton<IAdService>(() => AdService.createInstance());
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||||
import 'package:asciineverdie/src/core/animation/monster_size.dart';
|
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||||
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
497
lib/src/core/engine/arena_combat_simulator.dart
Normal file
497
lib/src/core/engine/arena_combat_simulator.dart
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
import 'package:asciineverdie/data/skill_data.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||||
|
|
||||||
|
/// 아레나 전투 시뮬레이터
|
||||||
|
///
|
||||||
|
/// ArenaService에서 분리된 전투 시뮬레이션 로직.
|
||||||
|
/// 스킬 시스템을 포함한 턴 기반 전투를 처리한다.
|
||||||
|
class ArenaCombatSimulator {
|
||||||
|
ArenaCombatSimulator({required DeterministicRandom rng})
|
||||||
|
: _rng = rng,
|
||||||
|
_skillService = SkillService(rng: rng);
|
||||||
|
|
||||||
|
final DeterministicRandom _rng;
|
||||||
|
final SkillService _skillService;
|
||||||
|
|
||||||
|
/// 전투 시뮬레이션 (애니메이션용 스트림)
|
||||||
|
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) async* {
|
||||||
|
final challengerStats = match.challenger.finalStats;
|
||||||
|
final opponentStats = match.opponent.finalStats;
|
||||||
|
|
||||||
|
if (challengerStats == null || opponentStats == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final calculator = CombatCalculator(rng: _rng);
|
||||||
|
|
||||||
|
// 스킬 ID 목록 로드
|
||||||
|
var challengerSkillIds = _getSkillIdsFromEntry(match.challenger);
|
||||||
|
var opponentSkillIds = _getSkillIdsFromEntry(match.opponent);
|
||||||
|
if (challengerSkillIds.isEmpty) {
|
||||||
|
challengerSkillIds = SkillData.defaultSkillIds;
|
||||||
|
}
|
||||||
|
if (opponentSkillIds.isEmpty) {
|
||||||
|
opponentSkillIds = SkillData.defaultSkillIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스킬 시스템 상태 초기화
|
||||||
|
var challengerSkillSystem = SkillSystemState.empty();
|
||||||
|
var opponentSkillSystem = SkillSystemState.empty();
|
||||||
|
|
||||||
|
// DOT 및 디버프 추적
|
||||||
|
var challengerDoTs = <DotEffect>[];
|
||||||
|
var opponentDoTs = <DotEffect>[];
|
||||||
|
var challengerDebuffs = <ActiveBuff>[];
|
||||||
|
var opponentDebuffs = <ActiveBuff>[];
|
||||||
|
|
||||||
|
var playerCombatStats = challengerStats.copyWith(
|
||||||
|
hpCurrent: challengerStats.hpMax,
|
||||||
|
mpCurrent: challengerStats.mpMax,
|
||||||
|
);
|
||||||
|
|
||||||
|
var opponentCombatStats = opponentStats.copyWith(
|
||||||
|
hpCurrent: opponentStats.hpMax,
|
||||||
|
mpCurrent: opponentStats.mpMax,
|
||||||
|
);
|
||||||
|
|
||||||
|
int playerAccum = 0;
|
||||||
|
int opponentAccum = 0;
|
||||||
|
int elapsedMs = 0;
|
||||||
|
const tickMs = 200;
|
||||||
|
int turns = 0;
|
||||||
|
|
||||||
|
// 초기 상태 전송
|
||||||
|
yield ArenaCombatTurn(
|
||||||
|
challengerHp: playerCombatStats.hpCurrent,
|
||||||
|
opponentHp: opponentCombatStats.hpCurrent,
|
||||||
|
challengerHpMax: playerCombatStats.hpMax,
|
||||||
|
opponentHpMax: opponentCombatStats.hpMax,
|
||||||
|
challengerMp: playerCombatStats.mpCurrent,
|
||||||
|
opponentMp: opponentCombatStats.mpCurrent,
|
||||||
|
challengerMpMax: playerCombatStats.mpMax,
|
||||||
|
opponentMpMax: opponentCombatStats.mpMax,
|
||||||
|
);
|
||||||
|
|
||||||
|
while (playerCombatStats.isAlive && opponentCombatStats.hpCurrent > 0) {
|
||||||
|
playerAccum += tickMs;
|
||||||
|
opponentAccum += tickMs;
|
||||||
|
elapsedMs += tickMs;
|
||||||
|
|
||||||
|
// 스킬 시스템 시간 업데이트
|
||||||
|
challengerSkillSystem = challengerSkillSystem.copyWith(
|
||||||
|
elapsedMs: elapsedMs,
|
||||||
|
);
|
||||||
|
opponentSkillSystem = opponentSkillSystem.copyWith(elapsedMs: elapsedMs);
|
||||||
|
|
||||||
|
int? challengerDamage;
|
||||||
|
int? opponentDamage;
|
||||||
|
bool isChallengerCritical = false;
|
||||||
|
bool isOpponentCritical = false;
|
||||||
|
bool isChallengerEvaded = false;
|
||||||
|
bool isOpponentEvaded = false;
|
||||||
|
bool isChallengerBlocked = false;
|
||||||
|
bool isOpponentBlocked = false;
|
||||||
|
String? challengerSkillUsed;
|
||||||
|
String? opponentSkillUsed;
|
||||||
|
int? challengerHealAmount;
|
||||||
|
int? opponentHealAmount;
|
||||||
|
|
||||||
|
// DOT 틱 처리
|
||||||
|
final dotResult = _processDotTicks(
|
||||||
|
challengerDoTs: challengerDoTs,
|
||||||
|
opponentDoTs: opponentDoTs,
|
||||||
|
playerStats: playerCombatStats,
|
||||||
|
opponentStats: opponentCombatStats,
|
||||||
|
tickMs: tickMs,
|
||||||
|
);
|
||||||
|
challengerDoTs = dotResult.challengerDoTs;
|
||||||
|
opponentDoTs = dotResult.opponentDoTs;
|
||||||
|
playerCombatStats = dotResult.playerStats;
|
||||||
|
opponentCombatStats = dotResult.opponentStats;
|
||||||
|
|
||||||
|
// 만료된 디버프 정리
|
||||||
|
challengerDebuffs = challengerDebuffs
|
||||||
|
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
||||||
|
.toList();
|
||||||
|
opponentDebuffs = opponentDebuffs
|
||||||
|
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// 도전자 턴
|
||||||
|
if (playerAccum >= playerCombatStats.attackDelayMs) {
|
||||||
|
playerAccum = 0;
|
||||||
|
|
||||||
|
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||||
|
opponentCombatStats,
|
||||||
|
match.opponent.characterName,
|
||||||
|
);
|
||||||
|
|
||||||
|
final turnResult = _processCharacterTurn(
|
||||||
|
player: playerCombatStats,
|
||||||
|
target: opponentCombatStats,
|
||||||
|
targetMonster: opponentMonsterStats,
|
||||||
|
targetName: match.opponent.characterName,
|
||||||
|
entry: match.challenger,
|
||||||
|
skillIds: challengerSkillIds,
|
||||||
|
skillSystem: challengerSkillSystem,
|
||||||
|
activeDoTs: challengerDoTs,
|
||||||
|
activeDebuffs: opponentDebuffs,
|
||||||
|
calculator: calculator,
|
||||||
|
elapsedMs: elapsedMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
playerCombatStats = turnResult.player;
|
||||||
|
opponentCombatStats = turnResult.target;
|
||||||
|
challengerSkillSystem = turnResult.skillSystem;
|
||||||
|
challengerDoTs = turnResult.activeDoTs;
|
||||||
|
opponentDebuffs = turnResult.targetDebuffs;
|
||||||
|
challengerDamage = turnResult.damage;
|
||||||
|
isChallengerCritical = turnResult.isCritical;
|
||||||
|
isOpponentEvaded = turnResult.isTargetEvaded;
|
||||||
|
challengerSkillUsed = turnResult.skillUsed;
|
||||||
|
challengerHealAmount = turnResult.healAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상대 턴
|
||||||
|
if (opponentCombatStats.hpCurrent > 0 &&
|
||||||
|
opponentAccum >= opponentCombatStats.attackDelayMs) {
|
||||||
|
opponentAccum = 0;
|
||||||
|
|
||||||
|
var challengerMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||||
|
playerCombatStats,
|
||||||
|
match.challenger.characterName,
|
||||||
|
);
|
||||||
|
|
||||||
|
final turnResult = _processCharacterTurn(
|
||||||
|
player: opponentCombatStats,
|
||||||
|
target: playerCombatStats,
|
||||||
|
targetMonster: challengerMonsterStats,
|
||||||
|
targetName: match.challenger.characterName,
|
||||||
|
entry: match.opponent,
|
||||||
|
skillIds: opponentSkillIds,
|
||||||
|
skillSystem: opponentSkillSystem,
|
||||||
|
activeDoTs: opponentDoTs,
|
||||||
|
activeDebuffs: challengerDebuffs,
|
||||||
|
calculator: calculator,
|
||||||
|
elapsedMs: elapsedMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
opponentCombatStats = turnResult.player;
|
||||||
|
playerCombatStats = turnResult.target;
|
||||||
|
opponentSkillSystem = turnResult.skillSystem;
|
||||||
|
opponentDoTs = turnResult.activeDoTs;
|
||||||
|
challengerDebuffs = turnResult.targetDebuffs;
|
||||||
|
opponentDamage = turnResult.damage;
|
||||||
|
isOpponentCritical = turnResult.isCritical;
|
||||||
|
isChallengerEvaded = turnResult.isTargetEvaded;
|
||||||
|
isChallengerBlocked = turnResult.isTargetBlocked;
|
||||||
|
opponentSkillUsed = turnResult.skillUsed;
|
||||||
|
opponentHealAmount = turnResult.healAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 액션이 발생했을 때만 턴 전송
|
||||||
|
final hasAction =
|
||||||
|
challengerDamage != null ||
|
||||||
|
opponentDamage != null ||
|
||||||
|
challengerHealAmount != null ||
|
||||||
|
opponentHealAmount != null ||
|
||||||
|
challengerSkillUsed != null ||
|
||||||
|
opponentSkillUsed != null;
|
||||||
|
|
||||||
|
if (hasAction) {
|
||||||
|
turns++;
|
||||||
|
yield ArenaCombatTurn(
|
||||||
|
challengerDamage: challengerDamage,
|
||||||
|
opponentDamage: opponentDamage,
|
||||||
|
challengerHp: playerCombatStats.hpCurrent,
|
||||||
|
opponentHp: opponentCombatStats.hpCurrent,
|
||||||
|
challengerHpMax: playerCombatStats.hpMax,
|
||||||
|
opponentHpMax: opponentCombatStats.hpMax,
|
||||||
|
challengerMp: playerCombatStats.mpCurrent,
|
||||||
|
opponentMp: opponentCombatStats.mpCurrent,
|
||||||
|
challengerMpMax: playerCombatStats.mpMax,
|
||||||
|
opponentMpMax: opponentCombatStats.mpMax,
|
||||||
|
isChallengerCritical: isChallengerCritical,
|
||||||
|
isOpponentCritical: isOpponentCritical,
|
||||||
|
isChallengerEvaded: isChallengerEvaded,
|
||||||
|
isOpponentEvaded: isOpponentEvaded,
|
||||||
|
isChallengerBlocked: isChallengerBlocked,
|
||||||
|
isOpponentBlocked: isOpponentBlocked,
|
||||||
|
challengerSkillUsed: challengerSkillUsed,
|
||||||
|
opponentSkillUsed: opponentSkillUsed,
|
||||||
|
challengerHealAmount: challengerHealAmount,
|
||||||
|
opponentHealAmount: opponentHealAmount,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turns > 1000) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DOT 틱 처리 (양측)
|
||||||
|
({
|
||||||
|
List<DotEffect> challengerDoTs,
|
||||||
|
List<DotEffect> opponentDoTs,
|
||||||
|
CombatStats playerStats,
|
||||||
|
CombatStats opponentStats,
|
||||||
|
})
|
||||||
|
_processDotTicks({
|
||||||
|
required List<DotEffect> challengerDoTs,
|
||||||
|
required List<DotEffect> opponentDoTs,
|
||||||
|
required CombatStats playerStats,
|
||||||
|
required CombatStats opponentStats,
|
||||||
|
required int tickMs,
|
||||||
|
}) {
|
||||||
|
var updatedPlayerStats = playerStats;
|
||||||
|
var updatedOpponentStats = opponentStats;
|
||||||
|
|
||||||
|
// 도전자 -> 상대에게 적용된 DOT
|
||||||
|
var dotDamageToOpponent = 0;
|
||||||
|
final updatedChallengerDoTs = <DotEffect>[];
|
||||||
|
for (final dot in challengerDoTs) {
|
||||||
|
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
||||||
|
if (ticksTriggered > 0) {
|
||||||
|
dotDamageToOpponent += dot.damagePerTick * ticksTriggered;
|
||||||
|
}
|
||||||
|
if (updatedDot.isActive) updatedChallengerDoTs.add(updatedDot);
|
||||||
|
}
|
||||||
|
if (dotDamageToOpponent > 0 && updatedOpponentStats.hpCurrent > 0) {
|
||||||
|
updatedOpponentStats = updatedOpponentStats.copyWith(
|
||||||
|
hpCurrent: (updatedOpponentStats.hpCurrent - dotDamageToOpponent).clamp(
|
||||||
|
0,
|
||||||
|
updatedOpponentStats.hpMax,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상대 -> 도전자에게 적용된 DOT
|
||||||
|
var dotDamageToChallenger = 0;
|
||||||
|
final updatedOpponentDoTs = <DotEffect>[];
|
||||||
|
for (final dot in opponentDoTs) {
|
||||||
|
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
||||||
|
if (ticksTriggered > 0) {
|
||||||
|
dotDamageToChallenger += dot.damagePerTick * ticksTriggered;
|
||||||
|
}
|
||||||
|
if (updatedDot.isActive) updatedOpponentDoTs.add(updatedDot);
|
||||||
|
}
|
||||||
|
if (dotDamageToChallenger > 0 && updatedPlayerStats.isAlive) {
|
||||||
|
updatedPlayerStats = updatedPlayerStats.copyWith(
|
||||||
|
hpCurrent: (updatedPlayerStats.hpCurrent - dotDamageToChallenger).clamp(
|
||||||
|
0,
|
||||||
|
updatedPlayerStats.hpMax,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
challengerDoTs: updatedChallengerDoTs,
|
||||||
|
opponentDoTs: updatedOpponentDoTs,
|
||||||
|
playerStats: updatedPlayerStats,
|
||||||
|
opponentStats: updatedOpponentStats,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 캐릭터 턴 처리 (도전자/상대 공통)
|
||||||
|
({
|
||||||
|
CombatStats player,
|
||||||
|
CombatStats target,
|
||||||
|
SkillSystemState skillSystem,
|
||||||
|
List<DotEffect> activeDoTs,
|
||||||
|
List<ActiveBuff> targetDebuffs,
|
||||||
|
int? damage,
|
||||||
|
bool isCritical,
|
||||||
|
bool isTargetEvaded,
|
||||||
|
bool isTargetBlocked,
|
||||||
|
String? skillUsed,
|
||||||
|
int? healAmount,
|
||||||
|
})
|
||||||
|
_processCharacterTurn({
|
||||||
|
required CombatStats player,
|
||||||
|
required CombatStats target,
|
||||||
|
required MonsterCombatStats targetMonster,
|
||||||
|
required String targetName,
|
||||||
|
required HallOfFameEntry entry,
|
||||||
|
required List<String> skillIds,
|
||||||
|
required SkillSystemState skillSystem,
|
||||||
|
required List<DotEffect> activeDoTs,
|
||||||
|
required List<ActiveBuff> activeDebuffs,
|
||||||
|
required CombatCalculator calculator,
|
||||||
|
required int elapsedMs,
|
||||||
|
}) {
|
||||||
|
int? damage;
|
||||||
|
bool isCritical = false;
|
||||||
|
bool isTargetEvaded = false;
|
||||||
|
bool isTargetBlocked = false;
|
||||||
|
String? skillUsed;
|
||||||
|
int? healAmount;
|
||||||
|
var updatedPlayer = player;
|
||||||
|
var updatedTarget = target;
|
||||||
|
var updatedSkillSystem = skillSystem;
|
||||||
|
var updatedDoTs = [...activeDoTs];
|
||||||
|
var updatedDebuffs = [...activeDebuffs];
|
||||||
|
|
||||||
|
final selectedSkill = _skillService.selectAutoSkill(
|
||||||
|
player: updatedPlayer,
|
||||||
|
monster: targetMonster,
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
availableSkillIds: skillIds,
|
||||||
|
activeDoTs: updatedDoTs,
|
||||||
|
activeDebuffs: updatedDebuffs,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||||
|
final skillRank = _getSkillRankFromEntry(entry, selectedSkill.id);
|
||||||
|
final skillResult = _skillService.useAttackSkillWithRank(
|
||||||
|
skill: selectedSkill,
|
||||||
|
player: updatedPlayer,
|
||||||
|
monster: targetMonster,
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
rank: skillRank,
|
||||||
|
);
|
||||||
|
updatedPlayer = skillResult.updatedPlayer;
|
||||||
|
updatedTarget = updatedTarget.copyWith(
|
||||||
|
hpCurrent: skillResult.updatedMonster.hpCurrent,
|
||||||
|
);
|
||||||
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||||
|
skillUsed = selectedSkill.name;
|
||||||
|
damage = skillResult.result.damage;
|
||||||
|
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||||
|
final skillResult = _skillService.useDotSkill(
|
||||||
|
skill: selectedSkill,
|
||||||
|
player: updatedPlayer,
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
playerInt: updatedPlayer.intelligence,
|
||||||
|
playerWis: updatedPlayer.wis,
|
||||||
|
);
|
||||||
|
updatedPlayer = skillResult.updatedPlayer;
|
||||||
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||||
|
if (skillResult.dotEffect != null) {
|
||||||
|
updatedDoTs.add(skillResult.dotEffect!);
|
||||||
|
}
|
||||||
|
skillUsed = selectedSkill.name;
|
||||||
|
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||||
|
final skillResult = _skillService.useHealSkill(
|
||||||
|
skill: selectedSkill,
|
||||||
|
player: updatedPlayer,
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
);
|
||||||
|
updatedPlayer = skillResult.updatedPlayer;
|
||||||
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||||
|
skillUsed = selectedSkill.name;
|
||||||
|
healAmount = skillResult.result.healedAmount;
|
||||||
|
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||||
|
final skillResult = _skillService.useBuffSkill(
|
||||||
|
skill: selectedSkill,
|
||||||
|
player: updatedPlayer,
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
);
|
||||||
|
updatedPlayer = skillResult.updatedPlayer;
|
||||||
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||||
|
skillUsed = selectedSkill.name;
|
||||||
|
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||||
|
final skillResult = _skillService.useDebuffSkill(
|
||||||
|
skill: selectedSkill,
|
||||||
|
player: updatedPlayer,
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
currentDebuffs: updatedDebuffs,
|
||||||
|
);
|
||||||
|
updatedPlayer = skillResult.updatedPlayer;
|
||||||
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||||
|
final debuffEffect = skillResult.debuffEffect;
|
||||||
|
if (debuffEffect != null) {
|
||||||
|
updatedDebuffs =
|
||||||
|
updatedDebuffs
|
||||||
|
.where((ActiveBuff d) => d.effect.id != debuffEffect.effect.id)
|
||||||
|
.toList()
|
||||||
|
..add(debuffEffect);
|
||||||
|
}
|
||||||
|
skillUsed = selectedSkill.name;
|
||||||
|
} else {
|
||||||
|
// 일반 공격
|
||||||
|
final opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||||
|
updatedTarget,
|
||||||
|
targetName,
|
||||||
|
);
|
||||||
|
final result = calculator.playerAttackMonster(
|
||||||
|
attacker: updatedPlayer,
|
||||||
|
defender: opponentMonsterStats,
|
||||||
|
);
|
||||||
|
updatedTarget = updatedTarget.copyWith(
|
||||||
|
hpCurrent: result.updatedDefender.hpCurrent,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.result.isHit) {
|
||||||
|
damage = result.result.damage;
|
||||||
|
isCritical = result.result.isCritical;
|
||||||
|
} else {
|
||||||
|
isTargetEvaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
player: updatedPlayer,
|
||||||
|
target: updatedTarget,
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
activeDoTs: updatedDoTs,
|
||||||
|
targetDebuffs: updatedDebuffs,
|
||||||
|
damage: damage,
|
||||||
|
isCritical: isCritical,
|
||||||
|
isTargetEvaded: isTargetEvaded,
|
||||||
|
isTargetBlocked: isTargetBlocked,
|
||||||
|
skillUsed: skillUsed,
|
||||||
|
healAmount: healAmount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 스킬 ID 목록 추출 (HallOfFameEntry에서)
|
||||||
|
List<String> _getSkillIdsFromEntry(HallOfFameEntry entry) {
|
||||||
|
final skillData = entry.finalSkills;
|
||||||
|
if (skillData == null || skillData.isEmpty) return [];
|
||||||
|
|
||||||
|
final skillIds = <String>[];
|
||||||
|
for (final data in skillData) {
|
||||||
|
final skillName = data['name'];
|
||||||
|
if (skillName != null) {
|
||||||
|
final skill = SkillData.getSkillBySpellName(skillName);
|
||||||
|
if (skill != null) {
|
||||||
|
skillIds.add(skill.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return skillIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 스킬 랭크 조회 (HallOfFameEntry의 finalSkills에서)
|
||||||
|
int _getSkillRankFromEntry(HallOfFameEntry entry, String skillId) {
|
||||||
|
final skill = SkillData.getSkillById(skillId);
|
||||||
|
if (skill == null) return 1;
|
||||||
|
|
||||||
|
final skillData = entry.finalSkills;
|
||||||
|
if (skillData == null || skillData.isEmpty) return 1;
|
||||||
|
|
||||||
|
for (final data in skillData) {
|
||||||
|
if (data['name'] == skill.name) {
|
||||||
|
final rankStr = data['rank'] ?? 'I';
|
||||||
|
return switch (rankStr) {
|
||||||
|
'I' => 1,
|
||||||
|
'II' => 2,
|
||||||
|
'III' => 3,
|
||||||
|
'IV' => 4,
|
||||||
|
'V' => 5,
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
import 'package:asciineverdie/data/skill_data.dart';
|
import 'package:asciineverdie/src/core/engine/arena_combat_simulator.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
|
||||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||||
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||||
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
|
||||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
import 'package:asciineverdie/src/core/model/skill.dart';
|
|
||||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||||
|
|
||||||
/// 아레나 서비스
|
/// 아레나 서비스
|
||||||
@@ -23,64 +20,6 @@ class ArenaService {
|
|||||||
|
|
||||||
final DeterministicRandom _rng;
|
final DeterministicRandom _rng;
|
||||||
|
|
||||||
late final SkillService _skillService = SkillService(rng: _rng);
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 스킬 시스템 헬퍼
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// HallOfFameEntry의 finalSkills에서 Skill 목록 추출
|
|
||||||
List<Skill> _getSkillsFromEntry(HallOfFameEntry entry) {
|
|
||||||
final skillData = entry.finalSkills;
|
|
||||||
if (skillData == null || skillData.isEmpty) return [];
|
|
||||||
|
|
||||||
final skills = <Skill>[];
|
|
||||||
for (final data in skillData) {
|
|
||||||
final skillName = data['name'];
|
|
||||||
if (skillName != null) {
|
|
||||||
final skill = SkillData.getSkillBySpellName(skillName);
|
|
||||||
if (skill != null) {
|
|
||||||
skills.add(skill);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return skills;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 스킬 ID 목록 추출 (HallOfFameEntry에서)
|
|
||||||
List<String> _getSkillIdsFromEntry(HallOfFameEntry entry) {
|
|
||||||
return _getSkillsFromEntry(entry).map((s) => s.id).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 스킬 랭크 조회 (HallOfFameEntry의 finalSkills에서)
|
|
||||||
int _getSkillRankFromEntry(HallOfFameEntry entry, String skillId) {
|
|
||||||
final skill = SkillData.getSkillById(skillId);
|
|
||||||
if (skill == null) return 1;
|
|
||||||
|
|
||||||
final skillData = entry.finalSkills;
|
|
||||||
if (skillData == null || skillData.isEmpty) return 1;
|
|
||||||
|
|
||||||
for (final data in skillData) {
|
|
||||||
if (data['name'] == skill.name) {
|
|
||||||
final rankStr = data['rank'] ?? 'I';
|
|
||||||
return _romanToInt(rankStr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 로마 숫자 → 정수 변환
|
|
||||||
int _romanToInt(String roman) {
|
|
||||||
return switch (roman) {
|
|
||||||
'I' => 1,
|
|
||||||
'II' => 2,
|
|
||||||
'III' => 3,
|
|
||||||
'IV' => 4,
|
|
||||||
'V' => 5,
|
|
||||||
_ => 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 상대 결정
|
// 상대 결정
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -230,452 +169,10 @@ class ArenaService {
|
|||||||
|
|
||||||
/// 전투 시뮬레이션 (애니메이션용 스트림)
|
/// 전투 시뮬레이션 (애니메이션용 스트림)
|
||||||
///
|
///
|
||||||
/// progress_service._processCombatTickWithSkills()와 동일한 로직 사용
|
/// ArenaCombatSimulator에 위임하여 턴별 전투 상황을 스트림으로 반환.
|
||||||
/// [match] 대전 정보
|
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) {
|
||||||
/// Returns: 턴별 전투 상황 스트림
|
final simulator = ArenaCombatSimulator(rng: _rng);
|
||||||
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) async* {
|
return simulator.simulateCombat(match);
|
||||||
final calculator = CombatCalculator(rng: _rng);
|
|
||||||
|
|
||||||
final challengerStats = match.challenger.finalStats;
|
|
||||||
final opponentStats = match.opponent.finalStats;
|
|
||||||
|
|
||||||
if (challengerStats == null || opponentStats == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 스킬 ID 목록 로드 (SkillBook과 동일한 방식)
|
|
||||||
var challengerSkillIds = _getSkillIdsFromEntry(match.challenger);
|
|
||||||
var opponentSkillIds = _getSkillIdsFromEntry(match.opponent);
|
|
||||||
|
|
||||||
// 스킬이 없으면 기본 스킬 사용
|
|
||||||
if (challengerSkillIds.isEmpty) {
|
|
||||||
challengerSkillIds = SkillData.defaultSkillIds;
|
|
||||||
}
|
|
||||||
if (opponentSkillIds.isEmpty) {
|
|
||||||
opponentSkillIds = SkillData.defaultSkillIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 스킬 시스템 상태 초기화
|
|
||||||
var challengerSkillSystem = SkillSystemState.empty();
|
|
||||||
var opponentSkillSystem = SkillSystemState.empty();
|
|
||||||
|
|
||||||
// DOT 및 디버프 추적 (일반 전투와 동일)
|
|
||||||
var challengerDoTs = <DotEffect>[];
|
|
||||||
var opponentDoTs = <DotEffect>[];
|
|
||||||
var challengerDebuffs = <ActiveBuff>[];
|
|
||||||
var opponentDebuffs = <ActiveBuff>[];
|
|
||||||
|
|
||||||
var playerCombatStats = challengerStats.copyWith(
|
|
||||||
hpCurrent: challengerStats.hpMax,
|
|
||||||
mpCurrent: challengerStats.mpMax,
|
|
||||||
);
|
|
||||||
|
|
||||||
var opponentCombatStats = opponentStats.copyWith(
|
|
||||||
hpCurrent: opponentStats.hpMax,
|
|
||||||
mpCurrent: opponentStats.mpMax,
|
|
||||||
);
|
|
||||||
|
|
||||||
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
|
||||||
opponentCombatStats,
|
|
||||||
match.opponent.characterName,
|
|
||||||
);
|
|
||||||
|
|
||||||
var challengerMonsterStats = MonsterCombatStats.fromCombatStats(
|
|
||||||
playerCombatStats,
|
|
||||||
match.challenger.characterName,
|
|
||||||
);
|
|
||||||
|
|
||||||
int playerAccum = 0;
|
|
||||||
int opponentAccum = 0;
|
|
||||||
int elapsedMs = 0;
|
|
||||||
const tickMs = 200;
|
|
||||||
int turns = 0;
|
|
||||||
|
|
||||||
// 초기 상태 전송
|
|
||||||
yield ArenaCombatTurn(
|
|
||||||
challengerHp: playerCombatStats.hpCurrent,
|
|
||||||
opponentHp: opponentCombatStats.hpCurrent,
|
|
||||||
challengerHpMax: playerCombatStats.hpMax,
|
|
||||||
opponentHpMax: opponentCombatStats.hpMax,
|
|
||||||
challengerMp: playerCombatStats.mpCurrent,
|
|
||||||
opponentMp: opponentCombatStats.mpCurrent,
|
|
||||||
challengerMpMax: playerCombatStats.mpMax,
|
|
||||||
opponentMpMax: opponentCombatStats.mpMax,
|
|
||||||
);
|
|
||||||
|
|
||||||
while (playerCombatStats.isAlive && opponentCombatStats.hpCurrent > 0) {
|
|
||||||
playerAccum += tickMs;
|
|
||||||
opponentAccum += tickMs;
|
|
||||||
elapsedMs += tickMs;
|
|
||||||
|
|
||||||
// 스킬 시스템 시간 업데이트
|
|
||||||
challengerSkillSystem = challengerSkillSystem.copyWith(
|
|
||||||
elapsedMs: elapsedMs,
|
|
||||||
);
|
|
||||||
opponentSkillSystem = opponentSkillSystem.copyWith(elapsedMs: elapsedMs);
|
|
||||||
|
|
||||||
int? challengerDamage;
|
|
||||||
int? opponentDamage;
|
|
||||||
bool isChallengerCritical = false;
|
|
||||||
bool isOpponentCritical = false;
|
|
||||||
bool isChallengerEvaded = false;
|
|
||||||
bool isOpponentEvaded = false;
|
|
||||||
bool isChallengerBlocked = false;
|
|
||||||
bool isOpponentBlocked = false;
|
|
||||||
String? challengerSkillUsed;
|
|
||||||
String? opponentSkillUsed;
|
|
||||||
int? challengerHealAmount;
|
|
||||||
int? opponentHealAmount;
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// DOT 틱 처리 (도전자 → 상대에게 적용된 DOT)
|
|
||||||
// =========================================================================
|
|
||||||
var dotDamageToOpponent = 0;
|
|
||||||
final updatedChallengerDoTs = <DotEffect>[];
|
|
||||||
for (final dot in challengerDoTs) {
|
|
||||||
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
|
||||||
if (ticksTriggered > 0) {
|
|
||||||
dotDamageToOpponent += dot.damagePerTick * ticksTriggered;
|
|
||||||
}
|
|
||||||
if (updatedDot.isActive) {
|
|
||||||
updatedChallengerDoTs.add(updatedDot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
challengerDoTs = updatedChallengerDoTs;
|
|
||||||
|
|
||||||
if (dotDamageToOpponent > 0 && opponentCombatStats.hpCurrent > 0) {
|
|
||||||
opponentCombatStats = opponentCombatStats.copyWith(
|
|
||||||
hpCurrent: (opponentCombatStats.hpCurrent - dotDamageToOpponent)
|
|
||||||
.clamp(0, opponentCombatStats.hpMax),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// DOT 틱 처리 (상대 → 도전자에게 적용된 DOT)
|
|
||||||
var dotDamageToChallenger = 0;
|
|
||||||
final updatedOpponentDoTs = <DotEffect>[];
|
|
||||||
for (final dot in opponentDoTs) {
|
|
||||||
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
|
||||||
if (ticksTriggered > 0) {
|
|
||||||
dotDamageToChallenger += dot.damagePerTick * ticksTriggered;
|
|
||||||
}
|
|
||||||
if (updatedDot.isActive) {
|
|
||||||
updatedOpponentDoTs.add(updatedDot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
opponentDoTs = updatedOpponentDoTs;
|
|
||||||
|
|
||||||
if (dotDamageToChallenger > 0 && playerCombatStats.isAlive) {
|
|
||||||
playerCombatStats = playerCombatStats.copyWith(
|
|
||||||
hpCurrent: (playerCombatStats.hpCurrent - dotDamageToChallenger)
|
|
||||||
.clamp(0, playerCombatStats.hpMax),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 만료된 디버프 정리
|
|
||||||
// =========================================================================
|
|
||||||
challengerDebuffs = challengerDebuffs
|
|
||||||
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
|
||||||
.toList();
|
|
||||||
opponentDebuffs = opponentDebuffs
|
|
||||||
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 도전자 턴 (selectAutoSkill 사용 - 일반 전투와 동일)
|
|
||||||
// =========================================================================
|
|
||||||
if (playerAccum >= playerCombatStats.attackDelayMs) {
|
|
||||||
playerAccum = 0;
|
|
||||||
|
|
||||||
// 상대 몬스터 스탯 동기화
|
|
||||||
opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
|
||||||
opponentCombatStats,
|
|
||||||
match.opponent.characterName,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 스킬 자동 선택 (progress_service와 동일한 로직)
|
|
||||||
final selectedSkill = _skillService.selectAutoSkill(
|
|
||||||
player: playerCombatStats,
|
|
||||||
monster: opponentMonsterStats,
|
|
||||||
skillSystem: challengerSkillSystem,
|
|
||||||
availableSkillIds: challengerSkillIds,
|
|
||||||
activeDoTs: challengerDoTs,
|
|
||||||
activeDebuffs: opponentDebuffs,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
|
||||||
// 스킬 랭크 조회 및 적용
|
|
||||||
final skillRank = _getSkillRankFromEntry(
|
|
||||||
match.challenger,
|
|
||||||
selectedSkill.id,
|
|
||||||
);
|
|
||||||
final skillResult = _skillService.useAttackSkillWithRank(
|
|
||||||
skill: selectedSkill,
|
|
||||||
player: playerCombatStats,
|
|
||||||
monster: opponentMonsterStats,
|
|
||||||
skillSystem: challengerSkillSystem,
|
|
||||||
rank: skillRank,
|
|
||||||
);
|
|
||||||
playerCombatStats = skillResult.updatedPlayer;
|
|
||||||
opponentCombatStats = opponentCombatStats.copyWith(
|
|
||||||
hpCurrent: skillResult.updatedMonster.hpCurrent,
|
|
||||||
);
|
|
||||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
|
||||||
challengerSkillUsed = selectedSkill.name;
|
|
||||||
challengerDamage = skillResult.result.damage;
|
|
||||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
|
||||||
// DOT 스킬 사용
|
|
||||||
final skillResult = _skillService.useDotSkill(
|
|
||||||
skill: selectedSkill,
|
|
||||||
player: playerCombatStats,
|
|
||||||
skillSystem: challengerSkillSystem,
|
|
||||||
playerInt: playerCombatStats.atk ~/ 10,
|
|
||||||
playerWis: playerCombatStats.def ~/ 10,
|
|
||||||
);
|
|
||||||
playerCombatStats = skillResult.updatedPlayer;
|
|
||||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
|
||||||
if (skillResult.dotEffect != null) {
|
|
||||||
challengerDoTs.add(skillResult.dotEffect!);
|
|
||||||
}
|
|
||||||
challengerSkillUsed = selectedSkill.name;
|
|
||||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
|
||||||
// 회복 스킬 사용
|
|
||||||
final skillResult = _skillService.useHealSkill(
|
|
||||||
skill: selectedSkill,
|
|
||||||
player: playerCombatStats,
|
|
||||||
skillSystem: challengerSkillSystem,
|
|
||||||
);
|
|
||||||
playerCombatStats = skillResult.updatedPlayer;
|
|
||||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
|
||||||
challengerSkillUsed = selectedSkill.name;
|
|
||||||
challengerHealAmount = skillResult.result.healedAmount;
|
|
||||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
|
||||||
// 버프 스킬 사용
|
|
||||||
final skillResult = _skillService.useBuffSkill(
|
|
||||||
skill: selectedSkill,
|
|
||||||
player: playerCombatStats,
|
|
||||||
skillSystem: challengerSkillSystem,
|
|
||||||
);
|
|
||||||
playerCombatStats = skillResult.updatedPlayer;
|
|
||||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
|
||||||
challengerSkillUsed = selectedSkill.name;
|
|
||||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
|
||||||
// 디버프 스킬 사용
|
|
||||||
final skillResult = _skillService.useDebuffSkill(
|
|
||||||
skill: selectedSkill,
|
|
||||||
player: playerCombatStats,
|
|
||||||
skillSystem: challengerSkillSystem,
|
|
||||||
currentDebuffs: opponentDebuffs,
|
|
||||||
);
|
|
||||||
playerCombatStats = skillResult.updatedPlayer;
|
|
||||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
|
||||||
final debuffEffect = skillResult.debuffEffect;
|
|
||||||
if (debuffEffect != null) {
|
|
||||||
opponentDebuffs =
|
|
||||||
opponentDebuffs
|
|
||||||
.where(
|
|
||||||
(ActiveBuff d) => d.effect.id != debuffEffect.effect.id,
|
|
||||||
)
|
|
||||||
.toList()
|
|
||||||
..add(debuffEffect);
|
|
||||||
}
|
|
||||||
challengerSkillUsed = selectedSkill.name;
|
|
||||||
} else {
|
|
||||||
// 일반 공격
|
|
||||||
final result = calculator.playerAttackMonster(
|
|
||||||
attacker: playerCombatStats,
|
|
||||||
defender: opponentMonsterStats,
|
|
||||||
);
|
|
||||||
opponentCombatStats = opponentCombatStats.copyWith(
|
|
||||||
hpCurrent: result.updatedDefender.hpCurrent,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.result.isHit) {
|
|
||||||
challengerDamage = result.result.damage;
|
|
||||||
isChallengerCritical = result.result.isCritical;
|
|
||||||
} else {
|
|
||||||
isOpponentEvaded = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 상대 턴 (selectAutoSkill 사용 - 일반 전투와 동일)
|
|
||||||
// =========================================================================
|
|
||||||
if (opponentCombatStats.hpCurrent > 0 &&
|
|
||||||
opponentAccum >= opponentCombatStats.attackDelayMs) {
|
|
||||||
opponentAccum = 0;
|
|
||||||
|
|
||||||
// 도전자 몬스터 스탯 동기화
|
|
||||||
challengerMonsterStats = MonsterCombatStats.fromCombatStats(
|
|
||||||
playerCombatStats,
|
|
||||||
match.challenger.characterName,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 스킬 자동 선택 (progress_service와 동일한 로직)
|
|
||||||
final selectedSkill = _skillService.selectAutoSkill(
|
|
||||||
player: opponentCombatStats,
|
|
||||||
monster: challengerMonsterStats,
|
|
||||||
skillSystem: opponentSkillSystem,
|
|
||||||
availableSkillIds: opponentSkillIds,
|
|
||||||
activeDoTs: opponentDoTs,
|
|
||||||
activeDebuffs: challengerDebuffs,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
|
||||||
// 스킬 랭크 조회 및 적용
|
|
||||||
final skillRank = _getSkillRankFromEntry(
|
|
||||||
match.opponent,
|
|
||||||
selectedSkill.id,
|
|
||||||
);
|
|
||||||
final skillResult = _skillService.useAttackSkillWithRank(
|
|
||||||
skill: selectedSkill,
|
|
||||||
player: opponentCombatStats,
|
|
||||||
monster: challengerMonsterStats,
|
|
||||||
skillSystem: opponentSkillSystem,
|
|
||||||
rank: skillRank,
|
|
||||||
);
|
|
||||||
opponentCombatStats = skillResult.updatedPlayer;
|
|
||||||
playerCombatStats = playerCombatStats.copyWith(
|
|
||||||
hpCurrent: skillResult.updatedMonster.hpCurrent,
|
|
||||||
);
|
|
||||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
|
||||||
opponentSkillUsed = selectedSkill.name;
|
|
||||||
opponentDamage = skillResult.result.damage;
|
|
||||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
|
||||||
// DOT 스킬 사용
|
|
||||||
final skillResult = _skillService.useDotSkill(
|
|
||||||
skill: selectedSkill,
|
|
||||||
player: opponentCombatStats,
|
|
||||||
skillSystem: opponentSkillSystem,
|
|
||||||
playerInt: opponentCombatStats.atk ~/ 10,
|
|
||||||
playerWis: opponentCombatStats.def ~/ 10,
|
|
||||||
);
|
|
||||||
opponentCombatStats = skillResult.updatedPlayer;
|
|
||||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
|
||||||
if (skillResult.dotEffect != null) {
|
|
||||||
opponentDoTs.add(skillResult.dotEffect!);
|
|
||||||
}
|
|
||||||
opponentSkillUsed = selectedSkill.name;
|
|
||||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
|
||||||
// 회복 스킬 사용
|
|
||||||
final skillResult = _skillService.useHealSkill(
|
|
||||||
skill: selectedSkill,
|
|
||||||
player: opponentCombatStats,
|
|
||||||
skillSystem: opponentSkillSystem,
|
|
||||||
);
|
|
||||||
opponentCombatStats = skillResult.updatedPlayer;
|
|
||||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
|
||||||
opponentSkillUsed = selectedSkill.name;
|
|
||||||
opponentHealAmount = skillResult.result.healedAmount;
|
|
||||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
|
||||||
// 버프 스킬 사용
|
|
||||||
final skillResult = _skillService.useBuffSkill(
|
|
||||||
skill: selectedSkill,
|
|
||||||
player: opponentCombatStats,
|
|
||||||
skillSystem: opponentSkillSystem,
|
|
||||||
);
|
|
||||||
opponentCombatStats = skillResult.updatedPlayer;
|
|
||||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
|
||||||
opponentSkillUsed = selectedSkill.name;
|
|
||||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
|
||||||
// 디버프 스킬 사용
|
|
||||||
final skillResult = _skillService.useDebuffSkill(
|
|
||||||
skill: selectedSkill,
|
|
||||||
player: opponentCombatStats,
|
|
||||||
skillSystem: opponentSkillSystem,
|
|
||||||
currentDebuffs: challengerDebuffs,
|
|
||||||
);
|
|
||||||
opponentCombatStats = skillResult.updatedPlayer;
|
|
||||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
|
||||||
final debuffEffect = skillResult.debuffEffect;
|
|
||||||
if (debuffEffect != null) {
|
|
||||||
challengerDebuffs =
|
|
||||||
challengerDebuffs
|
|
||||||
.where(
|
|
||||||
(ActiveBuff d) => d.effect.id != debuffEffect.effect.id,
|
|
||||||
)
|
|
||||||
.toList()
|
|
||||||
..add(debuffEffect);
|
|
||||||
}
|
|
||||||
opponentSkillUsed = selectedSkill.name;
|
|
||||||
} else {
|
|
||||||
// 일반 공격 (디버프 효과 적용)
|
|
||||||
var debuffedOpponent = opponentCombatStats;
|
|
||||||
if (challengerDebuffs.isNotEmpty) {
|
|
||||||
double atkMod = 0;
|
|
||||||
for (final debuff in challengerDebuffs) {
|
|
||||||
if (!debuff.isExpired(elapsedMs)) {
|
|
||||||
atkMod += debuff.effect.atkModifier;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final newAtk = (opponentCombatStats.atk * (1 + atkMod))
|
|
||||||
.round()
|
|
||||||
.clamp(opponentCombatStats.atk ~/ 10, opponentCombatStats.atk);
|
|
||||||
debuffedOpponent = opponentCombatStats.copyWith(atk: newAtk);
|
|
||||||
}
|
|
||||||
|
|
||||||
opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
|
||||||
debuffedOpponent,
|
|
||||||
match.opponent.characterName,
|
|
||||||
);
|
|
||||||
final result = calculator.monsterAttackPlayer(
|
|
||||||
attacker: opponentMonsterStats,
|
|
||||||
defender: playerCombatStats,
|
|
||||||
);
|
|
||||||
playerCombatStats = result.updatedDefender;
|
|
||||||
|
|
||||||
if (result.result.isHit) {
|
|
||||||
opponentDamage = result.result.damage;
|
|
||||||
isOpponentCritical = result.result.isCritical;
|
|
||||||
isChallengerBlocked = result.result.isBlocked;
|
|
||||||
} else {
|
|
||||||
isChallengerEvaded = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 액션이 발생했을 때만 턴 전송
|
|
||||||
final hasAction =
|
|
||||||
challengerDamage != null ||
|
|
||||||
opponentDamage != null ||
|
|
||||||
challengerHealAmount != null ||
|
|
||||||
opponentHealAmount != null ||
|
|
||||||
challengerSkillUsed != null ||
|
|
||||||
opponentSkillUsed != null;
|
|
||||||
|
|
||||||
if (hasAction) {
|
|
||||||
turns++;
|
|
||||||
yield ArenaCombatTurn(
|
|
||||||
challengerDamage: challengerDamage,
|
|
||||||
opponentDamage: opponentDamage,
|
|
||||||
challengerHp: playerCombatStats.hpCurrent,
|
|
||||||
opponentHp: opponentCombatStats.hpCurrent,
|
|
||||||
challengerHpMax: playerCombatStats.hpMax,
|
|
||||||
opponentHpMax: opponentCombatStats.hpMax,
|
|
||||||
challengerMp: playerCombatStats.mpCurrent,
|
|
||||||
opponentMp: opponentCombatStats.mpCurrent,
|
|
||||||
challengerMpMax: playerCombatStats.mpMax,
|
|
||||||
opponentMpMax: opponentCombatStats.mpMax,
|
|
||||||
isChallengerCritical: isChallengerCritical,
|
|
||||||
isOpponentCritical: isOpponentCritical,
|
|
||||||
isChallengerEvaded: isChallengerEvaded,
|
|
||||||
isOpponentEvaded: isOpponentEvaded,
|
|
||||||
isChallengerBlocked: isChallengerBlocked,
|
|
||||||
isOpponentBlocked: isOpponentBlocked,
|
|
||||||
challengerSkillUsed: challengerSkillUsed,
|
|
||||||
opponentSkillUsed: opponentSkillUsed,
|
|
||||||
challengerHealAmount: challengerHealAmount,
|
|
||||||
opponentHealAmount: opponentHealAmount,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 애니메이션을 위한 딜레이
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 무한 루프 방지
|
|
||||||
if (turns > 1000) break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// AI 베팅 슬롯 선택
|
// AI 베팅 슬롯 선택
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/src/core/engine/ad_service.dart';
|
import 'package:asciineverdie/src/core/infrastructure/ad_service.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
import 'package:asciineverdie/src/core/infrastructure/iap_service.dart';
|
||||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
|
||||||
/// 캐릭터 생성 굴리기/되돌리기 서비스
|
/// 캐릭터 생성 굴리기/되돌리기 서비스
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:asciineverdie/data/class_data.dart';
|
|||||||
import 'package:asciineverdie/data/skill_data.dart';
|
import 'package:asciineverdie/data/skill_data.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||||
import 'package:asciineverdie/src/core/model/class_traits.dart';
|
import 'package:asciineverdie/src/core/model/class_traits.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/player_attack_processor.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/potion_service.dart';
|
import 'package:asciineverdie/src/core/engine/potion_service.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||||
@@ -124,11 +125,22 @@ class CombatTickService {
|
|||||||
newEvents.addAll(potionResult.events);
|
newEvents.addAll(potionResult.events);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 스킬 버프 모디파이어 조회 (전투 계산에 적용)
|
||||||
|
final buffMods = updatedSkillSystem.totalBuffModifiers;
|
||||||
|
|
||||||
// 플레이어 공격 체크
|
// 플레이어 공격 체크
|
||||||
if (playerAccumulator >= playerStats.attackDelayMs) {
|
if (playerAccumulator >= playerStats.attackDelayMs) {
|
||||||
final attackResult = _processPlayerAttack(
|
// 크리티컬 확률 버프 적용
|
||||||
|
final buffedPlayerForAttack = buffMods.criMod != 0
|
||||||
|
? playerStats.copyWith(
|
||||||
|
criRate: (playerStats.criRate + buffMods.criMod).clamp(0.0, 1.0),
|
||||||
|
)
|
||||||
|
: playerStats;
|
||||||
|
|
||||||
|
final attackProcessor = PlayerAttackProcessor(rng: rng);
|
||||||
|
final attackResult = attackProcessor.processAttack(
|
||||||
state: state,
|
state: state,
|
||||||
playerStats: playerStats,
|
playerStats: buffedPlayerForAttack,
|
||||||
monsterStats: monsterStats,
|
monsterStats: monsterStats,
|
||||||
updatedSkillSystem: updatedSkillSystem,
|
updatedSkillSystem: updatedSkillSystem,
|
||||||
activeDoTs: activeDoTs,
|
activeDoTs: activeDoTs,
|
||||||
@@ -143,7 +155,14 @@ class CombatTickService {
|
|||||||
healingMultiplier: healingMultiplier,
|
healingMultiplier: healingMultiplier,
|
||||||
);
|
);
|
||||||
|
|
||||||
playerStats = attackResult.playerStats;
|
// 크리티컬 버프가 적용된 스탯에서 HP/MP만 원본에 반영
|
||||||
|
final attackedPlayer = attackResult.playerStats;
|
||||||
|
playerStats = buffMods.criMod != 0
|
||||||
|
? playerStats.copyWith(
|
||||||
|
hpCurrent: attackedPlayer.hpCurrent,
|
||||||
|
mpCurrent: attackedPlayer.mpCurrent,
|
||||||
|
)
|
||||||
|
: attackedPlayer;
|
||||||
monsterStats = attackResult.monsterStats;
|
monsterStats = attackResult.monsterStats;
|
||||||
updatedSkillSystem = attackResult.skillSystem;
|
updatedSkillSystem = attackResult.skillSystem;
|
||||||
activeDoTs = attackResult.activeDoTs;
|
activeDoTs = attackResult.activeDoTs;
|
||||||
@@ -159,8 +178,20 @@ class CombatTickService {
|
|||||||
// 몬스터가 살아있으면 반격
|
// 몬스터가 살아있으면 반격
|
||||||
if (monsterStats.isAlive &&
|
if (monsterStats.isAlive &&
|
||||||
monsterAccumulator >= monsterStats.attackDelayMs) {
|
monsterAccumulator >= monsterStats.attackDelayMs) {
|
||||||
|
// 방어력/회피율 버프 적용
|
||||||
|
final buffedPlayerForDefense =
|
||||||
|
(buffMods.defMod != 0 || buffMods.evasionMod != 0)
|
||||||
|
? playerStats.copyWith(
|
||||||
|
def: (playerStats.def * (1.0 + buffMods.defMod)).round(),
|
||||||
|
evasion: (playerStats.evasion + buffMods.evasionMod).clamp(
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: playerStats;
|
||||||
|
|
||||||
final monsterAttackResult = _processMonsterAttack(
|
final monsterAttackResult = _processMonsterAttack(
|
||||||
playerStats: playerStats,
|
playerStats: buffedPlayerForDefense,
|
||||||
monsterStats: monsterStats,
|
monsterStats: monsterStats,
|
||||||
activeDebuffs: activeDebuffs,
|
activeDebuffs: activeDebuffs,
|
||||||
totalDamageTaken: totalDamageTaken,
|
totalDamageTaken: totalDamageTaken,
|
||||||
@@ -168,7 +199,12 @@ class CombatTickService {
|
|||||||
calculator: calculator,
|
calculator: calculator,
|
||||||
);
|
);
|
||||||
|
|
||||||
playerStats = monsterAttackResult.playerStats;
|
// 버프 적용된 스탯에서 HP/MP만 원본에 반영
|
||||||
|
final defendedPlayer = monsterAttackResult.playerStats;
|
||||||
|
playerStats = playerStats.copyWith(
|
||||||
|
hpCurrent: defendedPlayer.hpCurrent,
|
||||||
|
mpCurrent: defendedPlayer.mpCurrent,
|
||||||
|
);
|
||||||
totalDamageTaken = monsterAttackResult.totalDamageTaken;
|
totalDamageTaken = monsterAttackResult.totalDamageTaken;
|
||||||
newEvents.addAll(monsterAttackResult.events);
|
newEvents.addAll(monsterAttackResult.events);
|
||||||
monsterAccumulator -= monsterStats.attackDelayMs;
|
monsterAccumulator -= monsterStats.attackDelayMs;
|
||||||
@@ -210,7 +246,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 +309,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,
|
||||||
@@ -361,251 +399,9 @@ class CombatTickService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 플레이어 공격 처리
|
|
||||||
({
|
|
||||||
CombatStats playerStats,
|
|
||||||
MonsterCombatStats monsterStats,
|
|
||||||
SkillSystemState skillSystem,
|
|
||||||
List<DotEffect> activeDoTs,
|
|
||||||
List<ActiveBuff> activeDebuffs,
|
|
||||||
int totalDamageDealt,
|
|
||||||
List<CombatEvent> events,
|
|
||||||
bool isFirstPlayerAttack,
|
|
||||||
}) _processPlayerAttack({
|
|
||||||
required GameState state,
|
|
||||||
required CombatStats playerStats,
|
|
||||||
required MonsterCombatStats monsterStats,
|
|
||||||
required SkillSystemState updatedSkillSystem,
|
|
||||||
required List<DotEffect> activeDoTs,
|
|
||||||
required List<ActiveBuff> activeDebuffs,
|
|
||||||
required int totalDamageDealt,
|
|
||||||
required int timestamp,
|
|
||||||
required CombatCalculator calculator,
|
|
||||||
required SkillService skillService,
|
|
||||||
required bool isFirstPlayerAttack,
|
|
||||||
required double firstStrikeBonus,
|
|
||||||
required bool hasMultiAttack,
|
|
||||||
double healingMultiplier = 1.0,
|
|
||||||
}) {
|
|
||||||
final events = <CombatEvent>[];
|
|
||||||
var newPlayerStats = playerStats;
|
|
||||||
var newMonsterStats = monsterStats;
|
|
||||||
var newSkillSystem = updatedSkillSystem;
|
|
||||||
var newActiveDoTs = [...activeDoTs];
|
|
||||||
var newActiveBuffs = [...activeDebuffs];
|
|
||||||
var newTotalDamageDealt = totalDamageDealt;
|
|
||||||
|
|
||||||
// 장착된 스킬 슬롯에서 사용 가능한 스킬 ID 목록 조회
|
|
||||||
var availableSkillIds = state.skillSystem.equippedSkills.allSkills
|
|
||||||
.map((s) => s.id)
|
|
||||||
.toList();
|
|
||||||
// 장착된 스킬이 없으면 기본 스킬 사용
|
|
||||||
if (availableSkillIds.isEmpty) {
|
|
||||||
availableSkillIds = SkillData.defaultSkillIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
final selectedSkill = skillService.selectAutoSkill(
|
|
||||||
player: newPlayerStats,
|
|
||||||
monster: newMonsterStats,
|
|
||||||
skillSystem: newSkillSystem,
|
|
||||||
availableSkillIds: availableSkillIds,
|
|
||||||
activeDoTs: newActiveDoTs,
|
|
||||||
activeDebuffs: newActiveBuffs,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
|
||||||
// 스킬 랭크 조회
|
|
||||||
final skillRank = skillService.getSkillRankFromSkillBook(
|
|
||||||
state.skillBook,
|
|
||||||
selectedSkill.id,
|
|
||||||
);
|
|
||||||
// 랭크 스케일링 적용된 공격 스킬 사용
|
|
||||||
final skillResult = skillService.useAttackSkillWithRank(
|
|
||||||
skill: selectedSkill,
|
|
||||||
player: newPlayerStats,
|
|
||||||
monster: newMonsterStats,
|
|
||||||
skillSystem: newSkillSystem,
|
|
||||||
rank: skillRank,
|
|
||||||
);
|
|
||||||
newPlayerStats = skillResult.updatedPlayer;
|
|
||||||
newMonsterStats = skillResult.updatedMonster;
|
|
||||||
newTotalDamageDealt += skillResult.result.damage;
|
|
||||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
|
||||||
|
|
||||||
events.add(
|
|
||||||
CombatEvent.playerSkill(
|
|
||||||
timestamp: timestamp,
|
|
||||||
skillName: selectedSkill.name,
|
|
||||||
damage: skillResult.result.damage,
|
|
||||||
targetName: newMonsterStats.name,
|
|
||||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
|
||||||
final skillResult = skillService.useDotSkill(
|
|
||||||
skill: selectedSkill,
|
|
||||||
player: newPlayerStats,
|
|
||||||
skillSystem: newSkillSystem,
|
|
||||||
playerInt: state.stats.intelligence,
|
|
||||||
playerWis: state.stats.wis,
|
|
||||||
);
|
|
||||||
newPlayerStats = skillResult.updatedPlayer;
|
|
||||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
|
||||||
|
|
||||||
if (skillResult.dotEffect != null) {
|
|
||||||
newActiveDoTs.add(skillResult.dotEffect!);
|
|
||||||
}
|
|
||||||
|
|
||||||
events.add(
|
|
||||||
CombatEvent.playerSkill(
|
|
||||||
timestamp: timestamp,
|
|
||||||
skillName: selectedSkill.name,
|
|
||||||
damage: skillResult.result.damage,
|
|
||||||
targetName: newMonsterStats.name,
|
|
||||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
|
||||||
final skillResult = skillService.useHealSkill(
|
|
||||||
skill: selectedSkill,
|
|
||||||
player: newPlayerStats,
|
|
||||||
skillSystem: newSkillSystem,
|
|
||||||
healingMultiplier: healingMultiplier,
|
|
||||||
);
|
|
||||||
newPlayerStats = skillResult.updatedPlayer;
|
|
||||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
|
||||||
|
|
||||||
events.add(
|
|
||||||
CombatEvent.playerHeal(
|
|
||||||
timestamp: timestamp,
|
|
||||||
healAmount: skillResult.result.healedAmount,
|
|
||||||
skillName: selectedSkill.name,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
|
||||||
final skillResult = skillService.useBuffSkill(
|
|
||||||
skill: selectedSkill,
|
|
||||||
player: newPlayerStats,
|
|
||||||
skillSystem: newSkillSystem,
|
|
||||||
);
|
|
||||||
newPlayerStats = skillResult.updatedPlayer;
|
|
||||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
|
||||||
|
|
||||||
events.add(
|
|
||||||
CombatEvent.playerBuff(
|
|
||||||
timestamp: timestamp,
|
|
||||||
skillName: selectedSkill.name,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
|
||||||
final skillResult = skillService.useDebuffSkill(
|
|
||||||
skill: selectedSkill,
|
|
||||||
player: newPlayerStats,
|
|
||||||
skillSystem: newSkillSystem,
|
|
||||||
currentDebuffs: newActiveBuffs,
|
|
||||||
);
|
|
||||||
newPlayerStats = skillResult.updatedPlayer;
|
|
||||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
|
||||||
|
|
||||||
if (skillResult.debuffEffect != null) {
|
|
||||||
newActiveBuffs = newActiveBuffs
|
|
||||||
.where((d) => d.effect.id != skillResult.debuffEffect!.effect.id)
|
|
||||||
.toList()
|
|
||||||
..add(skillResult.debuffEffect!);
|
|
||||||
}
|
|
||||||
|
|
||||||
events.add(
|
|
||||||
CombatEvent.playerDebuff(
|
|
||||||
timestamp: timestamp,
|
|
||||||
skillName: selectedSkill.name,
|
|
||||||
targetName: newMonsterStats.name,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 일반 공격
|
|
||||||
final attackResult = calculator.playerAttackMonster(
|
|
||||||
attacker: newPlayerStats,
|
|
||||||
defender: newMonsterStats,
|
|
||||||
);
|
|
||||||
newMonsterStats = attackResult.updatedDefender;
|
|
||||||
|
|
||||||
// 첫 공격 배율 적용 (예: Pointer Assassin 1.5배)
|
|
||||||
var damage = attackResult.result.damage;
|
|
||||||
if (isFirstPlayerAttack && firstStrikeBonus > 1.0) {
|
|
||||||
damage = (damage * firstStrikeBonus).round();
|
|
||||||
// 첫 공격 배율이 적용된 데미지로 몬스터 HP 재계산
|
|
||||||
final extraDamage = damage - attackResult.result.damage;
|
|
||||||
if (extraDamage > 0) {
|
|
||||||
final newHp = (newMonsterStats.hpCurrent - extraDamage).clamp(
|
|
||||||
0,
|
|
||||||
newMonsterStats.hpMax,
|
|
||||||
);
|
|
||||||
newMonsterStats = newMonsterStats.copyWith(hpCurrent: newHp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newTotalDamageDealt += damage;
|
|
||||||
|
|
||||||
final result = attackResult.result;
|
|
||||||
if (result.isEvaded) {
|
|
||||||
events.add(
|
|
||||||
CombatEvent.monsterEvade(
|
|
||||||
timestamp: timestamp,
|
|
||||||
targetName: newMonsterStats.name,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
events.add(
|
|
||||||
CombatEvent.playerAttack(
|
|
||||||
timestamp: timestamp,
|
|
||||||
damage: damage,
|
|
||||||
targetName: newMonsterStats.name,
|
|
||||||
isCritical: result.isCritical,
|
|
||||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 연속 공격 (Refactor Monk 패시브) - 30% 확률로 추가 공격
|
|
||||||
if (hasMultiAttack && newMonsterStats.isAlive && rng.nextDouble() < 0.3) {
|
|
||||||
final extraAttack = calculator.playerAttackMonster(
|
|
||||||
attacker: newPlayerStats,
|
|
||||||
defender: newMonsterStats,
|
|
||||||
);
|
|
||||||
newMonsterStats = extraAttack.updatedDefender;
|
|
||||||
newTotalDamageDealt += extraAttack.result.damage;
|
|
||||||
|
|
||||||
if (!extraAttack.result.isEvaded) {
|
|
||||||
events.add(
|
|
||||||
CombatEvent.playerAttack(
|
|
||||||
timestamp: timestamp,
|
|
||||||
damage: extraAttack.result.damage,
|
|
||||||
targetName: newMonsterStats.name,
|
|
||||||
isCritical: extraAttack.result.isCritical,
|
|
||||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
playerStats: newPlayerStats,
|
|
||||||
monsterStats: newMonsterStats,
|
|
||||||
skillSystem: newSkillSystem,
|
|
||||||
activeDoTs: newActiveDoTs,
|
|
||||||
activeDebuffs: newActiveBuffs,
|
|
||||||
totalDamageDealt: newTotalDamageDealt,
|
|
||||||
events: events,
|
|
||||||
isFirstPlayerAttack: false, // 첫 공격 이후에는 false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 몬스터 공격 처리
|
/// 몬스터 공격 처리
|
||||||
({
|
({CombatStats playerStats, int totalDamageTaken, List<CombatEvent> events})
|
||||||
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,
|
||||||
|
|||||||
172
lib/src/core/engine/death_handler.dart
Normal file
172
lib/src/core/engine/death_handler.dart
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||||
|
|
||||||
|
/// 플레이어 사망 처리 서비스
|
||||||
|
///
|
||||||
|
/// ProgressService에서 분리된 사망 관련 로직 담당:
|
||||||
|
/// - 장비 손실 계산
|
||||||
|
/// - 사망 정보 기록
|
||||||
|
/// - 보스전 레벨링 모드 진입
|
||||||
|
class DeathHandler {
|
||||||
|
const DeathHandler();
|
||||||
|
|
||||||
|
/// 플레이어 사망 처리 (Phase 4)
|
||||||
|
///
|
||||||
|
/// 모든 장비 상실 및 사망 정보 기록.
|
||||||
|
/// 보스전 사망 시: 장비 보호 + 5분 레벨링 모드 진입.
|
||||||
|
GameState processPlayerDeath(
|
||||||
|
GameState state, {
|
||||||
|
required String killerName,
|
||||||
|
required DeathCause cause,
|
||||||
|
}) {
|
||||||
|
// 사망 직전 전투 이벤트 저장 (최대 10개)
|
||||||
|
final lastCombatEvents =
|
||||||
|
state.progress.currentCombat?.recentEvents ?? const [];
|
||||||
|
|
||||||
|
// 보스전 사망 여부 확인 (최종 보스 fighting 상태)
|
||||||
|
final isBossDeath =
|
||||||
|
state.progress.finalBossState == FinalBossState.fighting;
|
||||||
|
|
||||||
|
// 보스전 사망이 아닐 경우에만 장비 손실
|
||||||
|
var newEquipment = state.equipment;
|
||||||
|
var lostCount = 0;
|
||||||
|
String? lostItemName;
|
||||||
|
EquipmentSlot? lostItemSlot;
|
||||||
|
ItemRarity? lostItemRarity;
|
||||||
|
EquipmentItem? lostEquipmentItem; // 광고 부활 시 복구용
|
||||||
|
|
||||||
|
if (!isBossDeath) {
|
||||||
|
final lossResult = _calculateEquipmentLoss(state);
|
||||||
|
newEquipment = lossResult.equipment;
|
||||||
|
lostCount = lossResult.lostCount;
|
||||||
|
lostItemName = lossResult.lostItemName;
|
||||||
|
lostItemSlot = lossResult.lostItemSlot;
|
||||||
|
lostItemRarity = lossResult.lostItemRarity;
|
||||||
|
lostEquipmentItem = lossResult.lostItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사망 정보 생성 (전투 로그 포함)
|
||||||
|
final deathInfo = DeathInfo(
|
||||||
|
cause: cause,
|
||||||
|
killerName: killerName,
|
||||||
|
lostEquipmentCount: lostCount,
|
||||||
|
lostItemName: lostItemName,
|
||||||
|
lostItemSlot: lostItemSlot,
|
||||||
|
lostItemRarity: lostItemRarity,
|
||||||
|
lostItem: lostEquipmentItem,
|
||||||
|
goldAtDeath: state.inventory.gold,
|
||||||
|
levelAtDeath: state.traits.level,
|
||||||
|
timestamp: state.skillSystem.elapsedMs,
|
||||||
|
lastCombatEvents: lastCombatEvents,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 보스전 사망 시 5분 레벨링 모드 진입
|
||||||
|
final bossLevelingEndTime = isBossDeath
|
||||||
|
? DateTime.now().millisecondsSinceEpoch +
|
||||||
|
(5 * 60 * 1000) // 5분
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 전투 상태 초기화 및 사망 횟수 증가
|
||||||
|
final progress = state.progress.copyWith(
|
||||||
|
clearCurrentCombat: true,
|
||||||
|
deathCount: state.progress.deathCount + 1,
|
||||||
|
bossLevelingEndTime: bossLevelingEndTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
return state.copyWith(
|
||||||
|
equipment: newEquipment,
|
||||||
|
progress: progress,
|
||||||
|
deathInfo: deathInfo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 장비 손실 계산
|
||||||
|
({
|
||||||
|
Equipment equipment,
|
||||||
|
int lostCount,
|
||||||
|
String? lostItemName,
|
||||||
|
EquipmentSlot? lostItemSlot,
|
||||||
|
ItemRarity? lostItemRarity,
|
||||||
|
EquipmentItem? lostItem,
|
||||||
|
})
|
||||||
|
_calculateEquipmentLoss(GameState state) {
|
||||||
|
var newEquipment = state.equipment;
|
||||||
|
|
||||||
|
// 레벨 기반 장비 손실 확률 계산
|
||||||
|
// Lv 1: 20%, Lv 5: ~56%, Lv 10+: 100%
|
||||||
|
// 공식: 20 + (level - 1) * 80 / 9
|
||||||
|
final level = state.traits.level;
|
||||||
|
final lossChancePercent = level >= 10
|
||||||
|
? 100
|
||||||
|
: (20 + ((level - 1) * 80 ~/ 9)).clamp(0, 100);
|
||||||
|
final roll = state.rng.nextInt(100); // 0~99
|
||||||
|
final shouldLoseEquipment = roll < lossChancePercent;
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'[Death] Lv$level lossChance=$lossChancePercent% roll=$roll '
|
||||||
|
'shouldLose=$shouldLoseEquipment',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shouldLoseEquipment) {
|
||||||
|
return (
|
||||||
|
equipment: newEquipment,
|
||||||
|
lostCount: 0,
|
||||||
|
lostItemName: null,
|
||||||
|
lostItemSlot: null,
|
||||||
|
lostItemRarity: null,
|
||||||
|
lostItem: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
|
||||||
|
final equippedNonWeaponSlots = <int>[];
|
||||||
|
for (var i = 1; i < Equipment.slotCount; i++) {
|
||||||
|
final item = state.equipment.getItemByIndex(i);
|
||||||
|
if (item.isNotEmpty) {
|
||||||
|
equippedNonWeaponSlots.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (equippedNonWeaponSlots.isEmpty) {
|
||||||
|
return (
|
||||||
|
equipment: newEquipment,
|
||||||
|
lostCount: 0,
|
||||||
|
lostItemName: null,
|
||||||
|
lostItemSlot: null,
|
||||||
|
lostItemRarity: null,
|
||||||
|
lostItem: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 랜덤하게 1개 슬롯 선택
|
||||||
|
final sacrificeIndex =
|
||||||
|
equippedNonWeaponSlots[state.rng.nextInt(
|
||||||
|
equippedNonWeaponSlots.length,
|
||||||
|
)];
|
||||||
|
|
||||||
|
// 제물로 바칠 아이템 정보 저장
|
||||||
|
final lostItem = state.equipment.getItemByIndex(sacrificeIndex);
|
||||||
|
final lostItemSlot = EquipmentSlot.values[sacrificeIndex];
|
||||||
|
|
||||||
|
// 해당 슬롯을 빈 장비로 교체
|
||||||
|
newEquipment = newEquipment.setItemByIndex(
|
||||||
|
sacrificeIndex,
|
||||||
|
EquipmentItem.empty(lostItemSlot),
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('[Death] Lost item: ${lostItem.name} (slot: $lostItemSlot)');
|
||||||
|
|
||||||
|
return (
|
||||||
|
equipment: newEquipment,
|
||||||
|
lostCount: 1,
|
||||||
|
lostItemName: lostItem.name,
|
||||||
|
lostItemSlot: lostItemSlot,
|
||||||
|
lostItemRarity: lostItem.rarity,
|
||||||
|
lostItem: lostItem,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
import 'package:asciineverdie/src/core/infrastructure/iap_service.dart';
|
||||||
|
|
||||||
/// 디버그 설정 서비스 (Phase 8)
|
/// 디버그 설정 서비스 (Phase 8)
|
||||||
///
|
///
|
||||||
|
|||||||
92
lib/src/core/engine/exp_handler.dart
Normal file
92
lib/src/core/engine/exp_handler.dart
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import 'package:asciineverdie/data/race_data.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
||||||
|
|
||||||
|
/// 경험치(EXP) 획득 및 레벨업(level-up) 처리를 담당하는 핸들러.
|
||||||
|
class ExpHandler {
|
||||||
|
const ExpHandler({
|
||||||
|
required this.mutations,
|
||||||
|
required this.rewards,
|
||||||
|
required this.recalculateEncumbrance,
|
||||||
|
});
|
||||||
|
|
||||||
|
final GameMutations mutations;
|
||||||
|
final RewardService rewards;
|
||||||
|
|
||||||
|
/// 무게(encumbrance) 재계산 콜백
|
||||||
|
final GameState Function(GameState) recalculateEncumbrance;
|
||||||
|
|
||||||
|
/// 경험치 획득 및 레벨업 처리
|
||||||
|
({GameState state, ProgressState progress, bool leveledUp}) handleExpGain(
|
||||||
|
GameState state,
|
||||||
|
ProgressState progress,
|
||||||
|
int monsterExpReward,
|
||||||
|
) {
|
||||||
|
var nextState = state;
|
||||||
|
var leveledUp = false;
|
||||||
|
|
||||||
|
final race = RaceData.findById(nextState.traits.raceId);
|
||||||
|
final expMultiplier = race?.expMultiplier ?? 1.0;
|
||||||
|
final adjustedExp = (monsterExpReward * expMultiplier).round();
|
||||||
|
final newExpPos = progress.exp.position + adjustedExp;
|
||||||
|
|
||||||
|
if (newExpPos >= progress.exp.max) {
|
||||||
|
final overflowExp = newExpPos - progress.exp.max;
|
||||||
|
nextState = levelUp(nextState);
|
||||||
|
leveledUp = true;
|
||||||
|
progress = nextState.progress;
|
||||||
|
|
||||||
|
if (overflowExp > 0 && nextState.traits.level < 100) {
|
||||||
|
progress = progress.copyWith(
|
||||||
|
exp: progress.exp.copyWith(position: overflowExp),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
progress = progress.copyWith(
|
||||||
|
exp: progress.exp.copyWith(position: newExpPos),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (state: nextState, progress: progress, leveledUp: leveledUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 레벨업 처리 (스탯 증가, 스킬/주문 획득)
|
||||||
|
GameState levelUp(GameState state) {
|
||||||
|
// 최대 레벨(100) 안전장치: 이미 100레벨이면 레벨업하지 않음
|
||||||
|
if (state.traits.level >= 100) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextLevel = state.traits.level + 1;
|
||||||
|
final rng = state.rng;
|
||||||
|
|
||||||
|
// HP/MP 증가량 (PlayerScaling 기반 + 랜덤 변동)
|
||||||
|
// 기존: CON/3 + 1 + random(0-3) → ~6-9 HP/레벨 (너무 낮음)
|
||||||
|
// 신규: 18 + CON/5 + random(0-4) → ~20-25 HP/레벨 (생존율 개선)
|
||||||
|
final hpGain = 18 + state.stats.con ~/ 5 + rng.nextInt(5);
|
||||||
|
final mpGain = 6 + state.stats.intelligence ~/ 5 + rng.nextInt(3);
|
||||||
|
|
||||||
|
var nextState = state.copyWith(
|
||||||
|
traits: state.traits.copyWith(level: nextLevel),
|
||||||
|
stats: state.stats.copyWith(
|
||||||
|
hpMax: state.stats.hpMax + hpGain,
|
||||||
|
mpMax: state.stats.mpMax + mpGain,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 스탯 2회, 주문(spell) 1회 획득 (원본 레벨업 규칙)
|
||||||
|
nextState = mutations.winStat(nextState);
|
||||||
|
nextState = mutations.winStat(nextState);
|
||||||
|
nextState = mutations.winSpell(nextState, nextState.stats.wis, nextLevel);
|
||||||
|
|
||||||
|
final expBar = ProgressBarState(
|
||||||
|
position: 0,
|
||||||
|
max: ExpConstants.requiredExp(nextLevel),
|
||||||
|
);
|
||||||
|
final progress = nextState.progress.copyWith(exp: expBar);
|
||||||
|
nextState = nextState.copyWith(progress: progress);
|
||||||
|
return recalculateEncumbrance(nextState);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import 'package:asciineverdie/src/core/model/game_state.dart';
|
|||||||
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
||||||
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||||
|
|
||||||
/// Game state mutations that mirror the original PQ win/reward logic.
|
/// 게임 상태 변경(mutation) 함수 — 보상, 장비, 스탯 획득 로직.
|
||||||
class GameMutations {
|
class GameMutations {
|
||||||
const GameMutations(this.config);
|
const GameMutations(this.config);
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ class GameMutations {
|
|||||||
name: name,
|
name: name,
|
||||||
slot: slot,
|
slot: slot,
|
||||||
level: level,
|
level: level,
|
||||||
|
cha: state.stats.cha,
|
||||||
);
|
);
|
||||||
|
|
||||||
final updatedEquip = state.equipment
|
final updatedEquip = state.equipment
|
||||||
|
|||||||
@@ -60,23 +60,34 @@ class ItemService {
|
|||||||
// 희귀도 결정
|
// 희귀도 결정
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// 희귀도 결정 (고정 확률)
|
/// 희귀도 결정 (고정 확률 + CHA 보정)
|
||||||
///
|
///
|
||||||
/// 확률 분포:
|
/// 기본 확률 분포:
|
||||||
/// - Common: 34%
|
/// - Common: 34%
|
||||||
/// - Uncommon: 40%
|
/// - Uncommon: 40%
|
||||||
/// - Rare: 20%
|
/// - Rare: 20%
|
||||||
/// - Epic: 5%
|
/// - Epic: 5%
|
||||||
/// - Legendary: 1%
|
/// - Legendary: 1%
|
||||||
ItemRarity determineRarity(int level) {
|
///
|
||||||
|
/// CHA 보정(charisma bonus): (CHA - 10) * 0.5% 추가 희귀 확률.
|
||||||
|
/// 보정값만큼 Common 확률이 줄고, Rare 이상 확률이 증가.
|
||||||
|
ItemRarity determineRarity(int level, {int cha = 0}) {
|
||||||
final roll = rng.nextInt(100);
|
final roll = rng.nextInt(100);
|
||||||
|
|
||||||
// Legendary: 0-0 (1%)
|
// CHA 보정: (CHA - 10) * 0.5, 0~10 범위
|
||||||
if (roll < 1) return ItemRarity.legendary;
|
final chaBonus = ((cha - 10) * 0.5).clamp(0.0, 10.0);
|
||||||
// Epic: 1-5 (5%)
|
|
||||||
if (roll < 6) return ItemRarity.epic;
|
// 보정된 임계값 (chaBonus만큼 희귀 쪽으로 이동)
|
||||||
// Rare: 6-25 (20%)
|
final legendaryThreshold = 1.0 + chaBonus * 0.1; // 최대 2%
|
||||||
if (roll < 26) return ItemRarity.rare;
|
final epicThreshold = 6.0 + chaBonus * 0.3; // 최대 9%
|
||||||
|
final rareThreshold = 26.0 + chaBonus * 0.6; // 최대 32%
|
||||||
|
|
||||||
|
// Legendary
|
||||||
|
if (roll < legendaryThreshold) return ItemRarity.legendary;
|
||||||
|
// Epic
|
||||||
|
if (roll < epicThreshold) return ItemRarity.epic;
|
||||||
|
// Rare
|
||||||
|
if (roll < rareThreshold) return ItemRarity.rare;
|
||||||
// Uncommon: 26-65 (40%)
|
// Uncommon: 26-65 (40%)
|
||||||
if (roll < 66) return ItemRarity.uncommon;
|
if (roll < 66) return ItemRarity.uncommon;
|
||||||
// Common: 66-99 (34%)
|
// Common: 66-99 (34%)
|
||||||
@@ -164,8 +175,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 +285,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,
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -331,8 +342,9 @@ class ItemService {
|
|||||||
required EquipmentSlot slot,
|
required EquipmentSlot slot,
|
||||||
required int level,
|
required int level,
|
||||||
ItemRarity? rarity,
|
ItemRarity? rarity,
|
||||||
|
int cha = 0,
|
||||||
}) {
|
}) {
|
||||||
final itemRarity = rarity ?? determineRarity(level);
|
final itemRarity = rarity ?? determineRarity(level, cha: cha);
|
||||||
final stats = generateItemStats(
|
final stats = generateItemStats(
|
||||||
level: level,
|
level: level,
|
||||||
rarity: itemRarity,
|
rarity: itemRarity,
|
||||||
|
|||||||
129
lib/src/core/engine/kill_task_handler.dart
Normal file
129
lib/src/core/engine/kill_task_handler.dart
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:asciineverdie/data/class_data.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/loot_handler.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/class_traits.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||||
|
|
||||||
|
/// 킬 태스크(kill task) 완료 시 처리를 담당하는 핸들러.
|
||||||
|
///
|
||||||
|
/// HP 회복, 전리품(loot) 획득, 보스 처리 등을 수행한다.
|
||||||
|
class KillTaskHandler {
|
||||||
|
const KillTaskHandler({
|
||||||
|
required this.config,
|
||||||
|
required this.lootHandler,
|
||||||
|
required this.completeActFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PqConfig config;
|
||||||
|
final LootHandler lootHandler;
|
||||||
|
|
||||||
|
/// Act 완료 처리 콜백 (ProgressService.completeAct 위임)
|
||||||
|
final ({GameState state, bool gameComplete}) Function(GameState)
|
||||||
|
completeActFn;
|
||||||
|
|
||||||
|
/// 킬 태스크 완료 처리 (HP 회복, 전리품, 보스 처리)
|
||||||
|
({
|
||||||
|
GameState state,
|
||||||
|
ProgressState progress,
|
||||||
|
QueueState queue,
|
||||||
|
ProgressTickResult? earlyReturn,
|
||||||
|
})
|
||||||
|
handle(GameState state, ProgressState progress, QueueState queue) {
|
||||||
|
var nextState = state;
|
||||||
|
|
||||||
|
// 전투 후 HP 회복(heal)
|
||||||
|
final combat = progress.currentCombat;
|
||||||
|
if (combat != null && combat.isActive) {
|
||||||
|
final remainingHp = combat.playerStats.hpCurrent;
|
||||||
|
final maxHp = combat.playerStats.hpMax;
|
||||||
|
final conBonus = nextState.stats.con ~/ 2;
|
||||||
|
var healAmount = (maxHp * 0.5).round() + conBonus;
|
||||||
|
|
||||||
|
final klass = ClassData.findById(nextState.traits.classId);
|
||||||
|
if (klass != null) {
|
||||||
|
final postCombatHealRate = klass.getPassiveValue(
|
||||||
|
ClassPassiveType.postCombatHeal,
|
||||||
|
);
|
||||||
|
if (postCombatHealRate > 0) {
|
||||||
|
healAmount += (maxHp * postCombatHealRate).round();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final newHp = (remainingHp + healAmount).clamp(0, maxHp);
|
||||||
|
nextState = nextState.copyWith(
|
||||||
|
stats: nextState.stats.copyWith(hpCurrent: newHp),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전리품(loot) 획득
|
||||||
|
final lootResult = lootHandler.winLoot(nextState);
|
||||||
|
nextState = lootResult.state;
|
||||||
|
|
||||||
|
// 물약(potion) 드랍 로그 추가
|
||||||
|
var combatForReset = progress.currentCombat;
|
||||||
|
if (lootResult.droppedPotion != null && combatForReset != null) {
|
||||||
|
final potionDropEvent = CombatEvent.potionDrop(
|
||||||
|
timestamp: nextState.skillSystem.elapsedMs,
|
||||||
|
potionName: lootResult.droppedPotion!.name,
|
||||||
|
isHp: lootResult.droppedPotion!.isHpPotion,
|
||||||
|
);
|
||||||
|
final updatedEvents = [...combatForReset.recentEvents, potionDropEvent];
|
||||||
|
combatForReset = combatForReset.copyWith(
|
||||||
|
recentEvents: updatedEvents.length > 10
|
||||||
|
? updatedEvents.sublist(updatedEvents.length - 10)
|
||||||
|
: updatedEvents,
|
||||||
|
);
|
||||||
|
progress = progress.copyWith(currentCombat: combatForReset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 보스(Boss) 승리 처리
|
||||||
|
if (progress.pendingActCompletion) {
|
||||||
|
final cinematicEntries = pq_logic.interplotCinematic(
|
||||||
|
config,
|
||||||
|
nextState.rng,
|
||||||
|
nextState.traits.level,
|
||||||
|
progress.plotStageCount,
|
||||||
|
);
|
||||||
|
queue = QueueState(entries: [...queue.entries, ...cinematicEntries]);
|
||||||
|
progress = progress.copyWith(
|
||||||
|
currentCombat: null,
|
||||||
|
monstersKilled: progress.monstersKilled + 1,
|
||||||
|
pendingActCompletion: false,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
progress = progress.copyWith(
|
||||||
|
currentCombat: null,
|
||||||
|
monstersKilled: progress.monstersKilled + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextState = nextState.copyWith(progress: progress, queue: queue);
|
||||||
|
|
||||||
|
// 최종 보스(final boss) 처치 체크
|
||||||
|
if (progress.finalBossState == FinalBossState.fighting) {
|
||||||
|
progress = progress.copyWith(finalBossState: FinalBossState.defeated);
|
||||||
|
nextState = nextState.copyWith(progress: progress);
|
||||||
|
final actResult = completeActFn(nextState);
|
||||||
|
return (
|
||||||
|
state: actResult.state,
|
||||||
|
progress: actResult.state.progress,
|
||||||
|
queue: actResult.state.queue,
|
||||||
|
earlyReturn: ProgressTickResult(
|
||||||
|
state: actResult.state,
|
||||||
|
completedAct: true,
|
||||||
|
gameComplete: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
state: nextState,
|
||||||
|
progress: progress,
|
||||||
|
queue: queue,
|
||||||
|
earlyReturn: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
lib/src/core/engine/loot_handler.dart
Normal file
80
lib/src/core/engine/loot_handler.dart
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/potion_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/potion.dart';
|
||||||
|
|
||||||
|
/// 전리품 처리 서비스
|
||||||
|
///
|
||||||
|
/// ProgressService에서 분리된 전리품 획득 로직 담당:
|
||||||
|
/// - 몬스터 부위 아이템 인벤토리 추가
|
||||||
|
/// - 특수 아이템 획득 (WinItem)
|
||||||
|
/// - 물약 드랍
|
||||||
|
class LootHandler {
|
||||||
|
const LootHandler({required this.mutations});
|
||||||
|
|
||||||
|
final GameMutations mutations;
|
||||||
|
|
||||||
|
/// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630)
|
||||||
|
({GameState state, Potion? droppedPotion}) winLoot(GameState state) {
|
||||||
|
final taskInfo = state.progress.currentTask;
|
||||||
|
final monsterPart = taskInfo.monsterPart ?? '';
|
||||||
|
final monsterBaseName = taskInfo.monsterBaseName ?? '';
|
||||||
|
|
||||||
|
var resultState = state;
|
||||||
|
|
||||||
|
// 부위가 '*'이면 WinItem 호출 (특수 아이템)
|
||||||
|
if (monsterPart == '*') {
|
||||||
|
resultState = mutations.winItem(resultState);
|
||||||
|
} else if (monsterPart.isNotEmpty && monsterBaseName.isNotEmpty) {
|
||||||
|
// 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' +
|
||||||
|
// ProperCase(Split(fTask.Caption,3))), 1);
|
||||||
|
// 예: "goblin Claw" 형태로 인벤토리 추가
|
||||||
|
final itemName =
|
||||||
|
'${monsterBaseName.toLowerCase()} ${_properCase(monsterPart)}';
|
||||||
|
|
||||||
|
// 인벤토리에 추가
|
||||||
|
final items = [...resultState.inventory.items];
|
||||||
|
final existing = items.indexWhere((e) => e.name == itemName);
|
||||||
|
if (existing >= 0) {
|
||||||
|
items[existing] = items[existing].copyWith(
|
||||||
|
count: items[existing].count + 1,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
items.add(InventoryEntry(name: itemName, count: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
resultState = resultState.copyWith(
|
||||||
|
inventory: resultState.inventory.copyWith(items: items),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 물약 드랍 시도
|
||||||
|
final potionService = const PotionService();
|
||||||
|
final rng = resultState.rng;
|
||||||
|
final monsterLevel = taskInfo.monsterLevel ?? resultState.traits.level;
|
||||||
|
final monsterGrade = taskInfo.monsterGrade ?? MonsterGrade.normal;
|
||||||
|
final (updatedPotionInventory, droppedPotion) = potionService.tryPotionDrop(
|
||||||
|
playerLevel: resultState.traits.level,
|
||||||
|
monsterLevel: monsterLevel,
|
||||||
|
monsterGrade: monsterGrade,
|
||||||
|
inventory: resultState.potionInventory,
|
||||||
|
roll: rng.nextInt(100),
|
||||||
|
typeRoll: rng.nextInt(100),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
state: resultState.copyWith(
|
||||||
|
rng: rng,
|
||||||
|
potionInventory: updatedPotionInventory,
|
||||||
|
),
|
||||||
|
droppedPotion: droppedPotion,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 첫 글자만 대문자로 변환 (원본 ProperCase)
|
||||||
|
String _properCase(String s) {
|
||||||
|
if (s.isEmpty) return s;
|
||||||
|
return s[0].toUpperCase() + s.substring(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -59,7 +56,7 @@ class MarketService {
|
|||||||
slot: slot,
|
slot: slot,
|
||||||
targetRarity: ItemRarity.common,
|
targetRarity: ItemRarity.common,
|
||||||
);
|
);
|
||||||
final price = shopService.calculateBuyPrice(item);
|
final price = shopService.calculateBuyPrice(item, cha: state.stats.cha);
|
||||||
|
|
||||||
if (nextState.inventory.gold >= price) {
|
if (nextState.inventory.gold >= price) {
|
||||||
nextState = nextState.copyWith(
|
nextState = nextState.copyWith(
|
||||||
|
|||||||
411
lib/src/core/engine/player_attack_processor.dart
Normal file
411
lib/src/core/engine/player_attack_processor.dart
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import 'package:asciineverdie/data/skill_data.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||||
|
|
||||||
|
/// 플레이어 공격 처리 결과
|
||||||
|
typedef PlayerAttackResult = ({
|
||||||
|
CombatStats playerStats,
|
||||||
|
MonsterCombatStats monsterStats,
|
||||||
|
SkillSystemState skillSystem,
|
||||||
|
List<DotEffect> activeDoTs,
|
||||||
|
List<ActiveBuff> activeDebuffs,
|
||||||
|
int totalDamageDealt,
|
||||||
|
List<CombatEvent> events,
|
||||||
|
bool isFirstPlayerAttack,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 플레이어 공격 처리 서비스
|
||||||
|
///
|
||||||
|
/// CombatTickService에서 분리된 플레이어 공격 로직 담당:
|
||||||
|
/// - 스킬 자동 선택 및 사용
|
||||||
|
/// - 일반 공격 처리
|
||||||
|
/// - 첫 공격 보너스
|
||||||
|
/// - 연속 공격 (Multi-attack)
|
||||||
|
class PlayerAttackProcessor {
|
||||||
|
PlayerAttackProcessor({required this.rng});
|
||||||
|
|
||||||
|
final DeterministicRandom rng;
|
||||||
|
|
||||||
|
/// 플레이어 공격 처리
|
||||||
|
PlayerAttackResult processAttack({
|
||||||
|
required GameState state,
|
||||||
|
required CombatStats playerStats,
|
||||||
|
required MonsterCombatStats monsterStats,
|
||||||
|
required SkillSystemState updatedSkillSystem,
|
||||||
|
required List<DotEffect> activeDoTs,
|
||||||
|
required List<ActiveBuff> activeDebuffs,
|
||||||
|
required int totalDamageDealt,
|
||||||
|
required int timestamp,
|
||||||
|
required CombatCalculator calculator,
|
||||||
|
required SkillService skillService,
|
||||||
|
required bool isFirstPlayerAttack,
|
||||||
|
required double firstStrikeBonus,
|
||||||
|
required bool hasMultiAttack,
|
||||||
|
double healingMultiplier = 1.0,
|
||||||
|
}) {
|
||||||
|
final events = <CombatEvent>[];
|
||||||
|
var newPlayerStats = playerStats;
|
||||||
|
var newMonsterStats = monsterStats;
|
||||||
|
var newSkillSystem = updatedSkillSystem;
|
||||||
|
var newActiveDoTs = [...activeDoTs];
|
||||||
|
var newActiveBuffs = [...activeDebuffs];
|
||||||
|
var newTotalDamageDealt = totalDamageDealt;
|
||||||
|
|
||||||
|
// 장착된 스킬 슬롯에서 사용 가능한 스킬 ID 목록 조회
|
||||||
|
var availableSkillIds = state.skillSystem.equippedSkills.allSkills
|
||||||
|
.map((s) => s.id)
|
||||||
|
.toList();
|
||||||
|
// 장착된 스킬이 없으면 기본 스킬 사용
|
||||||
|
if (availableSkillIds.isEmpty) {
|
||||||
|
availableSkillIds = SkillData.defaultSkillIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
final selectedSkill = skillService.selectAutoSkill(
|
||||||
|
player: newPlayerStats,
|
||||||
|
monster: newMonsterStats,
|
||||||
|
skillSystem: newSkillSystem,
|
||||||
|
availableSkillIds: availableSkillIds,
|
||||||
|
activeDoTs: newActiveDoTs,
|
||||||
|
activeDebuffs: newActiveBuffs,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||||
|
final result = _useAttackSkill(
|
||||||
|
state: state,
|
||||||
|
skill: selectedSkill,
|
||||||
|
playerStats: newPlayerStats,
|
||||||
|
monsterStats: newMonsterStats,
|
||||||
|
skillSystem: newSkillSystem,
|
||||||
|
skillService: skillService,
|
||||||
|
timestamp: timestamp,
|
||||||
|
);
|
||||||
|
newPlayerStats = result.playerStats;
|
||||||
|
newMonsterStats = result.monsterStats;
|
||||||
|
newTotalDamageDealt += result.damage;
|
||||||
|
newSkillSystem = result.skillSystem;
|
||||||
|
events.add(result.event);
|
||||||
|
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||||
|
final result = _useDotSkill(
|
||||||
|
state: state,
|
||||||
|
skill: selectedSkill,
|
||||||
|
playerStats: newPlayerStats,
|
||||||
|
skillSystem: newSkillSystem,
|
||||||
|
skillService: skillService,
|
||||||
|
monsterName: newMonsterStats.name,
|
||||||
|
timestamp: timestamp,
|
||||||
|
);
|
||||||
|
newPlayerStats = result.playerStats;
|
||||||
|
newSkillSystem = result.skillSystem;
|
||||||
|
if (result.dotEffect != null) newActiveDoTs.add(result.dotEffect!);
|
||||||
|
events.add(result.event);
|
||||||
|
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||||
|
final result = _useHealSkill(
|
||||||
|
skill: selectedSkill,
|
||||||
|
playerStats: newPlayerStats,
|
||||||
|
skillSystem: newSkillSystem,
|
||||||
|
skillService: skillService,
|
||||||
|
healingMultiplier: healingMultiplier,
|
||||||
|
timestamp: timestamp,
|
||||||
|
);
|
||||||
|
newPlayerStats = result.playerStats;
|
||||||
|
newSkillSystem = result.skillSystem;
|
||||||
|
events.add(result.event);
|
||||||
|
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||||
|
final result = skillService.useBuffSkill(
|
||||||
|
skill: selectedSkill,
|
||||||
|
player: newPlayerStats,
|
||||||
|
skillSystem: newSkillSystem,
|
||||||
|
);
|
||||||
|
newPlayerStats = result.updatedPlayer;
|
||||||
|
newSkillSystem = result.updatedSkillSystem.startGlobalCooldown();
|
||||||
|
events.add(
|
||||||
|
CombatEvent.playerBuff(
|
||||||
|
timestamp: timestamp,
|
||||||
|
skillName: selectedSkill.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||||
|
final result = _useDebuffSkill(
|
||||||
|
skill: selectedSkill,
|
||||||
|
playerStats: newPlayerStats,
|
||||||
|
skillSystem: newSkillSystem,
|
||||||
|
skillService: skillService,
|
||||||
|
activeDebuffs: newActiveBuffs,
|
||||||
|
monsterName: newMonsterStats.name,
|
||||||
|
timestamp: timestamp,
|
||||||
|
);
|
||||||
|
newPlayerStats = result.playerStats;
|
||||||
|
newSkillSystem = result.skillSystem;
|
||||||
|
newActiveBuffs = result.activeDebuffs;
|
||||||
|
events.add(result.event);
|
||||||
|
} else {
|
||||||
|
// 일반 공격
|
||||||
|
final result = _processNormalAttack(
|
||||||
|
playerStats: newPlayerStats,
|
||||||
|
monsterStats: newMonsterStats,
|
||||||
|
calculator: calculator,
|
||||||
|
isFirstPlayerAttack: isFirstPlayerAttack,
|
||||||
|
firstStrikeBonus: firstStrikeBonus,
|
||||||
|
hasMultiAttack: hasMultiAttack,
|
||||||
|
timestamp: timestamp,
|
||||||
|
);
|
||||||
|
newMonsterStats = result.monsterStats;
|
||||||
|
newTotalDamageDealt += result.totalDamage;
|
||||||
|
events.addAll(result.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
playerStats: newPlayerStats,
|
||||||
|
monsterStats: newMonsterStats,
|
||||||
|
skillSystem: newSkillSystem,
|
||||||
|
activeDoTs: newActiveDoTs,
|
||||||
|
activeDebuffs: newActiveBuffs,
|
||||||
|
totalDamageDealt: newTotalDamageDealt,
|
||||||
|
events: events,
|
||||||
|
isFirstPlayerAttack: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 스킬 사용 헬퍼
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
({
|
||||||
|
CombatStats playerStats,
|
||||||
|
MonsterCombatStats monsterStats,
|
||||||
|
int damage,
|
||||||
|
SkillSystemState skillSystem,
|
||||||
|
CombatEvent event,
|
||||||
|
})
|
||||||
|
_useAttackSkill({
|
||||||
|
required GameState state,
|
||||||
|
required Skill skill,
|
||||||
|
required CombatStats playerStats,
|
||||||
|
required MonsterCombatStats monsterStats,
|
||||||
|
required SkillSystemState skillSystem,
|
||||||
|
required SkillService skillService,
|
||||||
|
required int timestamp,
|
||||||
|
}) {
|
||||||
|
final skillRank = skillService.getSkillRankFromSkillBook(
|
||||||
|
state.skillBook,
|
||||||
|
skill.id,
|
||||||
|
);
|
||||||
|
final skillResult = skillService.useAttackSkillWithRank(
|
||||||
|
skill: skill,
|
||||||
|
player: playerStats,
|
||||||
|
monster: monsterStats,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
rank: skillRank,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
playerStats: skillResult.updatedPlayer,
|
||||||
|
monsterStats: skillResult.updatedMonster,
|
||||||
|
damage: skillResult.result.damage,
|
||||||
|
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
|
||||||
|
event: CombatEvent.playerSkill(
|
||||||
|
timestamp: timestamp,
|
||||||
|
skillName: skill.name,
|
||||||
|
damage: skillResult.result.damage,
|
||||||
|
targetName: monsterStats.name,
|
||||||
|
attackDelayMs: playerStats.attackDelayMs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
({
|
||||||
|
CombatStats playerStats,
|
||||||
|
SkillSystemState skillSystem,
|
||||||
|
DotEffect? dotEffect,
|
||||||
|
CombatEvent event,
|
||||||
|
})
|
||||||
|
_useDotSkill({
|
||||||
|
required GameState state,
|
||||||
|
required Skill skill,
|
||||||
|
required CombatStats playerStats,
|
||||||
|
required SkillSystemState skillSystem,
|
||||||
|
required SkillService skillService,
|
||||||
|
required String monsterName,
|
||||||
|
required int timestamp,
|
||||||
|
}) {
|
||||||
|
final skillResult = skillService.useDotSkill(
|
||||||
|
skill: skill,
|
||||||
|
player: playerStats,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
playerInt: state.stats.intelligence,
|
||||||
|
playerWis: state.stats.wis,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
playerStats: skillResult.updatedPlayer,
|
||||||
|
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
|
||||||
|
dotEffect: skillResult.dotEffect,
|
||||||
|
event: CombatEvent.playerSkill(
|
||||||
|
timestamp: timestamp,
|
||||||
|
skillName: skill.name,
|
||||||
|
damage: skillResult.result.damage,
|
||||||
|
targetName: monsterName,
|
||||||
|
attackDelayMs: playerStats.attackDelayMs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
({CombatStats playerStats, SkillSystemState skillSystem, CombatEvent event})
|
||||||
|
_useHealSkill({
|
||||||
|
required Skill skill,
|
||||||
|
required CombatStats playerStats,
|
||||||
|
required SkillSystemState skillSystem,
|
||||||
|
required SkillService skillService,
|
||||||
|
required double healingMultiplier,
|
||||||
|
required int timestamp,
|
||||||
|
}) {
|
||||||
|
final skillResult = skillService.useHealSkill(
|
||||||
|
skill: skill,
|
||||||
|
player: playerStats,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
healingMultiplier: healingMultiplier,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
playerStats: skillResult.updatedPlayer,
|
||||||
|
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
|
||||||
|
event: CombatEvent.playerHeal(
|
||||||
|
timestamp: timestamp,
|
||||||
|
healAmount: skillResult.result.healedAmount,
|
||||||
|
skillName: skill.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
({
|
||||||
|
CombatStats playerStats,
|
||||||
|
SkillSystemState skillSystem,
|
||||||
|
List<ActiveBuff> activeDebuffs,
|
||||||
|
CombatEvent event,
|
||||||
|
})
|
||||||
|
_useDebuffSkill({
|
||||||
|
required Skill skill,
|
||||||
|
required CombatStats playerStats,
|
||||||
|
required SkillSystemState skillSystem,
|
||||||
|
required SkillService skillService,
|
||||||
|
required List<ActiveBuff> activeDebuffs,
|
||||||
|
required String monsterName,
|
||||||
|
required int timestamp,
|
||||||
|
}) {
|
||||||
|
final skillResult = skillService.useDebuffSkill(
|
||||||
|
skill: skill,
|
||||||
|
player: playerStats,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
currentDebuffs: activeDebuffs,
|
||||||
|
);
|
||||||
|
var newDebuffs = activeDebuffs;
|
||||||
|
if (skillResult.debuffEffect != null) {
|
||||||
|
newDebuffs =
|
||||||
|
activeDebuffs
|
||||||
|
.where((d) => d.effect.id != skillResult.debuffEffect!.effect.id)
|
||||||
|
.toList()
|
||||||
|
..add(skillResult.debuffEffect!);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
playerStats: skillResult.updatedPlayer,
|
||||||
|
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
|
||||||
|
activeDebuffs: newDebuffs,
|
||||||
|
event: CombatEvent.playerDebuff(
|
||||||
|
timestamp: timestamp,
|
||||||
|
skillName: skill.name,
|
||||||
|
targetName: monsterName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 일반 공격
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
({MonsterCombatStats monsterStats, int totalDamage, List<CombatEvent> events})
|
||||||
|
_processNormalAttack({
|
||||||
|
required CombatStats playerStats,
|
||||||
|
required MonsterCombatStats monsterStats,
|
||||||
|
required CombatCalculator calculator,
|
||||||
|
required bool isFirstPlayerAttack,
|
||||||
|
required double firstStrikeBonus,
|
||||||
|
required bool hasMultiAttack,
|
||||||
|
required int timestamp,
|
||||||
|
}) {
|
||||||
|
final events = <CombatEvent>[];
|
||||||
|
var newMonsterStats = monsterStats;
|
||||||
|
var totalDamage = 0;
|
||||||
|
|
||||||
|
final attackResult = calculator.playerAttackMonster(
|
||||||
|
attacker: playerStats,
|
||||||
|
defender: newMonsterStats,
|
||||||
|
);
|
||||||
|
newMonsterStats = attackResult.updatedDefender;
|
||||||
|
|
||||||
|
// 첫 공격 배율 적용
|
||||||
|
var damage = attackResult.result.damage;
|
||||||
|
if (isFirstPlayerAttack && firstStrikeBonus > 1.0) {
|
||||||
|
damage = (damage * firstStrikeBonus).round();
|
||||||
|
final extraDamage = damage - attackResult.result.damage;
|
||||||
|
if (extraDamage > 0) {
|
||||||
|
final newHp = (newMonsterStats.hpCurrent - extraDamage).clamp(
|
||||||
|
0,
|
||||||
|
newMonsterStats.hpMax,
|
||||||
|
);
|
||||||
|
newMonsterStats = newMonsterStats.copyWith(hpCurrent: newHp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalDamage += damage;
|
||||||
|
|
||||||
|
final result = attackResult.result;
|
||||||
|
if (result.isEvaded) {
|
||||||
|
events.add(
|
||||||
|
CombatEvent.monsterEvade(
|
||||||
|
timestamp: timestamp,
|
||||||
|
targetName: newMonsterStats.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
events.add(
|
||||||
|
CombatEvent.playerAttack(
|
||||||
|
timestamp: timestamp,
|
||||||
|
damage: damage,
|
||||||
|
targetName: newMonsterStats.name,
|
||||||
|
isCritical: result.isCritical,
|
||||||
|
attackDelayMs: playerStats.attackDelayMs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연속 공격 (Refactor Monk 패시브) - 30% 확률로 추가 공격
|
||||||
|
if (hasMultiAttack && newMonsterStats.isAlive && rng.nextDouble() < 0.3) {
|
||||||
|
final extraAttack = calculator.playerAttackMonster(
|
||||||
|
attacker: playerStats,
|
||||||
|
defender: newMonsterStats,
|
||||||
|
);
|
||||||
|
newMonsterStats = extraAttack.updatedDefender;
|
||||||
|
totalDamage += extraAttack.result.damage;
|
||||||
|
|
||||||
|
if (!extraAttack.result.isEvaded) {
|
||||||
|
events.add(
|
||||||
|
CombatEvent.playerAttack(
|
||||||
|
timestamp: timestamp,
|
||||||
|
damage: extraAttack.result.damage,
|
||||||
|
targetName: newMonsterStats.name,
|
||||||
|
isCritical: extraAttack.result.isCritical,
|
||||||
|
attackDelayMs: playerStats.attackDelayMs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
monsterStats: newMonsterStats,
|
||||||
|
totalDamage: totalDamage,
|
||||||
|
events: events,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
177
lib/src/core/engine/quest_completion_handler.dart
Normal file
177
lib/src/core/engine/quest_completion_handler.dart
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import 'package:asciineverdie/src/core/engine/act_progression_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||||
|
|
||||||
|
/// 퀘스트(quest) 완료 및 플롯(plot) 진행을 담당하는 핸들러.
|
||||||
|
class QuestCompletionHandler {
|
||||||
|
const QuestCompletionHandler({
|
||||||
|
required this.config,
|
||||||
|
required this.rewards,
|
||||||
|
required this.recalculateEncumbrance,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PqConfig config;
|
||||||
|
final RewardService rewards;
|
||||||
|
|
||||||
|
/// 무게(encumbrance) 재계산 콜백
|
||||||
|
final GameState Function(GameState) recalculateEncumbrance;
|
||||||
|
|
||||||
|
/// 퀘스트 진행 처리
|
||||||
|
({GameState state, ProgressState progress, QueueState queue, bool completed})
|
||||||
|
handleQuestProgress(
|
||||||
|
GameState state,
|
||||||
|
ProgressState progress,
|
||||||
|
QueueState queue,
|
||||||
|
bool gain,
|
||||||
|
int incrementSeconds,
|
||||||
|
) {
|
||||||
|
var nextState = state;
|
||||||
|
var questDone = false;
|
||||||
|
|
||||||
|
final canQuestProgress =
|
||||||
|
gain &&
|
||||||
|
progress.plotStageCount > 1 &&
|
||||||
|
progress.questCount > 0 &&
|
||||||
|
progress.quest.max > 0;
|
||||||
|
|
||||||
|
if (canQuestProgress) {
|
||||||
|
if (progress.quest.position + incrementSeconds >= progress.quest.max) {
|
||||||
|
nextState = completeQuest(nextState);
|
||||||
|
questDone = true;
|
||||||
|
progress = nextState.progress;
|
||||||
|
queue = nextState.queue;
|
||||||
|
} else {
|
||||||
|
progress = progress.copyWith(
|
||||||
|
quest: progress.quest.copyWith(
|
||||||
|
position: progress.quest.position + incrementSeconds,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
state: nextState,
|
||||||
|
progress: progress,
|
||||||
|
queue: queue,
|
||||||
|
completed: questDone,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 퀘스트 완료 처리 (보상 적용, 다음 퀘스트 생성)
|
||||||
|
GameState completeQuest(GameState state) {
|
||||||
|
final result = pq_logic.completeQuest(
|
||||||
|
config,
|
||||||
|
state.rng,
|
||||||
|
state.traits.level,
|
||||||
|
);
|
||||||
|
|
||||||
|
var nextState = _applyReward(state, result.reward);
|
||||||
|
final questCount = nextState.progress.questCount + 1;
|
||||||
|
|
||||||
|
// 퀘스트 히스토리(history) 업데이트: 이전 퀘스트 완료 표시, 새 퀘스트 추가
|
||||||
|
final updatedQuestHistory = [
|
||||||
|
...nextState.progress.questHistory.map(
|
||||||
|
(e) => e.isComplete ? e : e.copyWith(isComplete: true),
|
||||||
|
),
|
||||||
|
HistoryEntry(caption: result.caption, isComplete: false),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 퀘스트 몬스터 정보 저장 (Exterminate 타입용)
|
||||||
|
final questMonster = result.monsterIndex != null
|
||||||
|
? QuestMonsterInfo(
|
||||||
|
monsterData: result.monsterName!,
|
||||||
|
monsterIndex: result.monsterIndex!,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 큐에 퀘스트 태스크 추가
|
||||||
|
final updatedQueue = QueueState(
|
||||||
|
entries: [
|
||||||
|
...nextState.queue.entries,
|
||||||
|
QueueEntry(
|
||||||
|
kind: QueueKind.task,
|
||||||
|
durationMillis: 50 + nextState.rng.nextInt(100),
|
||||||
|
caption: result.caption,
|
||||||
|
taskType: TaskType.neutral,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 퀘스트 진행 바(bar) 리셋
|
||||||
|
final progress = nextState.progress.copyWith(
|
||||||
|
quest: ProgressBarState(
|
||||||
|
position: 0,
|
||||||
|
max: 50 + nextState.rng.nextInt(100),
|
||||||
|
),
|
||||||
|
questCount: questCount,
|
||||||
|
questHistory: updatedQuestHistory,
|
||||||
|
currentQuestMonster: questMonster,
|
||||||
|
);
|
||||||
|
|
||||||
|
return recalculateEncumbrance(
|
||||||
|
nextState.copyWith(progress: progress, queue: updatedQueue),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 플롯 진행 및 Act 보스(Boss) 소환 처리
|
||||||
|
ProgressState handlePlotProgress(
|
||||||
|
GameState state,
|
||||||
|
ProgressState progress,
|
||||||
|
bool gain,
|
||||||
|
int incrementSeconds,
|
||||||
|
) {
|
||||||
|
if (gain &&
|
||||||
|
progress.plot.max > 0 &&
|
||||||
|
progress.plot.position >= progress.plot.max &&
|
||||||
|
!progress.pendingActCompletion) {
|
||||||
|
final actProgressionService = ActProgressionService(config: config);
|
||||||
|
final actBoss = actProgressionService.createActBoss(state);
|
||||||
|
return progress.copyWith(
|
||||||
|
plot: progress.plot.copyWith(position: 0),
|
||||||
|
currentCombat: actBoss,
|
||||||
|
pendingActCompletion: true,
|
||||||
|
);
|
||||||
|
} else if (progress.currentTask.type != TaskType.load &&
|
||||||
|
progress.plot.max > 0 &&
|
||||||
|
!progress.pendingActCompletion) {
|
||||||
|
final uncappedPlot = progress.plot.position + incrementSeconds;
|
||||||
|
final int newPlotPos = uncappedPlot > progress.plot.max
|
||||||
|
? progress.plot.max
|
||||||
|
: uncappedPlot;
|
||||||
|
return progress.copyWith(
|
||||||
|
plot: progress.plot.copyWith(position: newPlotPos),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Act 완료 처리 (보상 적용 후 다음 Act로 진행)
|
||||||
|
/// gameComplete=true이면 최종 보스 격파로 게임 종료.
|
||||||
|
({GameState state, bool gameComplete}) completeAct(GameState state) {
|
||||||
|
final actProgressionService = ActProgressionService(config: config);
|
||||||
|
|
||||||
|
// Act 보상 먼저 적용
|
||||||
|
final actRewards = actProgressionService.getActRewards(
|
||||||
|
state.progress.plotStageCount,
|
||||||
|
);
|
||||||
|
var nextState = state;
|
||||||
|
for (final reward in actRewards) {
|
||||||
|
nextState = _applyReward(nextState, reward);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act 완료 처리 (ActProgressionService 위임)
|
||||||
|
final result = actProgressionService.completeAct(nextState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
state: recalculateEncumbrance(result.state),
|
||||||
|
gameComplete: result.gameComplete,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GameState _applyReward(GameState state, pq_logic.RewardKind reward) {
|
||||||
|
final updated = rewards.applyReward(state, reward);
|
||||||
|
return recalculateEncumbrance(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ class ResurrectionService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 전투 상태 초기화
|
// 전투 상태 초기화
|
||||||
final progress = state.progress.copyWith(currentCombat: null);
|
final progress = state.progress.copyWith(clearCurrentCombat: true);
|
||||||
|
|
||||||
return state.copyWith(
|
return state.copyWith(
|
||||||
equipment: newEquipment,
|
equipment: newEquipment,
|
||||||
@@ -105,6 +105,7 @@ class ResurrectionService {
|
|||||||
playerLevel: state.traits.level,
|
playerLevel: state.traits.level,
|
||||||
currentGold: state.inventory.gold,
|
currentGold: state.inventory.gold,
|
||||||
currentEquipment: state.equipment,
|
currentEquipment: state.equipment,
|
||||||
|
cha: state.stats.cha,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 장비 적용
|
// 장비 적용
|
||||||
@@ -346,7 +347,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(
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/src/core/engine/ad_service.dart';
|
import 'package:asciineverdie/src/core/infrastructure/ad_service.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/chest_service.dart';
|
import 'package:asciineverdie/src/core/engine/chest_service.dart';
|
||||||
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
import 'package:asciineverdie/src/core/infrastructure/iap_service.dart';
|
||||||
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
|
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
|
||||||
|
|
||||||
/// 복귀 보상 서비스 (Phase 7)
|
/// 복귀 보상 서비스 (Phase 7)
|
||||||
@@ -83,8 +83,13 @@ class ReturnRewardsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 유료 유저: 오프라인 시간 2배 인정
|
||||||
|
final creditedHours = isPaidUser ? hoursAway * 2 : hoursAway;
|
||||||
|
|
||||||
// 최대 시간 초과 시 최대로 제한
|
// 최대 시간 초과 시 최대로 제한
|
||||||
final effectiveHours = hoursAway > maxHoursAway ? maxHoursAway : hoursAway;
|
final effectiveHours = creditedHours > maxHoursAway
|
||||||
|
? maxHoursAway
|
||||||
|
: creditedHours;
|
||||||
|
|
||||||
// 상자 개수 계산
|
// 상자 개수 계산
|
||||||
final maxChests = isPaidUser ? maxChestsPaid : maxChestsFree;
|
final maxChests = isPaidUser ? maxChestsPaid : maxChestsFree;
|
||||||
@@ -94,8 +99,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 +132,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,23 +158,27 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 무료 유저는 리워드 광고 필요
|
// 무료 유저는 리워드 광고 필요
|
||||||
List<ChestReward> bonusRewards = [];
|
List<ChestReward> bonusRewards = [];
|
||||||
final adResult = await AdService.instance.showRewardedAd(
|
final adResult = await AdService.instance.showRewardedAd(
|
||||||
adType: AdType.rewardRevive, // 복귀 보상용 리워드 광고
|
adType: AdType.rewardReturn, // 복귀 보상용 리워드 광고
|
||||||
onRewarded: () {
|
onRewarded: () {
|
||||||
bonusRewards = openChests(reward.bonusChestCount, playerLevel);
|
bonusRewards = openChests(reward.bonusChestCount, playerLevel);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,21 @@ class ShopService {
|
|||||||
|
|
||||||
/// 장비 구매 가격 계산
|
/// 장비 구매 가격 계산
|
||||||
///
|
///
|
||||||
/// 가격 = 아이템 레벨 * 50 * 희귀도 배율
|
/// 가격 = 아이템 레벨 * 50 * 희귀도 배율 * (1 - CHA 할인율)
|
||||||
int calculateBuyPrice(EquipmentItem item) {
|
/// CHA 할인율(charisma discount): (CHA - 10) * 1%, 최대 15%, 최소 0%
|
||||||
|
int calculateBuyPrice(EquipmentItem item, {int cha = 0}) {
|
||||||
if (item.isEmpty) return 0;
|
if (item.isEmpty) return 0;
|
||||||
return (item.level * 50 * _getRarityPriceMultiplier(item.rarity)).round();
|
final basePrice = (item.level * 50 * _getRarityPriceMultiplier(item.rarity))
|
||||||
|
.round();
|
||||||
|
final discount = chaDiscount(cha);
|
||||||
|
return (basePrice * (1.0 - discount)).round();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CHA 기반 할인율 계산
|
||||||
|
///
|
||||||
|
/// (CHA - 10) * 0.01, 0~0.15 범위로 클램프
|
||||||
|
static double chaDiscount(int cha) {
|
||||||
|
return ((cha - 10) * 0.01).clamp(0.0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 장비 판매 가격 계산
|
/// 장비 판매 가격 계산
|
||||||
@@ -211,6 +222,7 @@ class ShopService {
|
|||||||
required int playerLevel,
|
required int playerLevel,
|
||||||
required int currentGold,
|
required int currentGold,
|
||||||
required Equipment currentEquipment,
|
required Equipment currentEquipment,
|
||||||
|
int cha = 0,
|
||||||
}) {
|
}) {
|
||||||
var remainingGold = currentGold;
|
var remainingGold = currentGold;
|
||||||
final purchasedItems = <EquipmentItem>[];
|
final purchasedItems = <EquipmentItem>[];
|
||||||
@@ -230,7 +242,7 @@ class ShopService {
|
|||||||
targetRarity: ItemRarity.common, // 부활 시 Common만 구매
|
targetRarity: ItemRarity.common, // 부활 시 Common만 구매
|
||||||
);
|
);
|
||||||
|
|
||||||
final price = calculateBuyPrice(shopItem);
|
final price = calculateBuyPrice(shopItem, cha: cha);
|
||||||
if (price <= remainingGold) {
|
if (price <= remainingGold) {
|
||||||
remainingGold -= price;
|
remainingGold -= price;
|
||||||
purchasedItems.add(shopItem);
|
purchasedItems.add(shopItem);
|
||||||
@@ -254,6 +266,7 @@ class ShopService {
|
|||||||
required int currentGold,
|
required int currentGold,
|
||||||
required EquipmentSlot slot,
|
required EquipmentSlot slot,
|
||||||
ItemRarity? preferredRarity,
|
ItemRarity? preferredRarity,
|
||||||
|
int cha = 0,
|
||||||
}) {
|
}) {
|
||||||
final item = generateShopItem(
|
final item = generateShopItem(
|
||||||
playerLevel: playerLevel,
|
playerLevel: playerLevel,
|
||||||
@@ -261,7 +274,7 @@ class ShopService {
|
|||||||
targetRarity: preferredRarity,
|
targetRarity: preferredRarity,
|
||||||
);
|
);
|
||||||
|
|
||||||
final price = calculateBuyPrice(item);
|
final price = calculateBuyPrice(item, cha: cha);
|
||||||
if (price > currentGold) return null;
|
if (price > currentGold) return null;
|
||||||
|
|
||||||
return PurchaseResult(
|
return PurchaseResult(
|
||||||
|
|||||||
200
lib/src/core/engine/skill_auto_selector.dart
Normal file
200
lib/src/core/engine/skill_auto_selector.dart
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import 'package:asciineverdie/data/skill_data.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||||
|
|
||||||
|
/// 스킬 자동 선택 AI
|
||||||
|
///
|
||||||
|
/// SkillService에서 분리된 전투 중 스킬 자동 선택 로직.
|
||||||
|
/// 상황별 우선순위에 따라 최적의 스킬을 선택한다.
|
||||||
|
class SkillAutoSelector {
|
||||||
|
const SkillAutoSelector({required this.rng});
|
||||||
|
|
||||||
|
final DeterministicRandom rng;
|
||||||
|
|
||||||
|
/// 전투 중 자동 스킬 선택
|
||||||
|
///
|
||||||
|
/// 우선순위:
|
||||||
|
/// 1. HP < 30% -> 회복 스킬 (최우선)
|
||||||
|
/// 2. 70% 확률로 일반 공격 (MP 절약, 기본 전투)
|
||||||
|
/// 3. 30% 확률로 스킬 사용:
|
||||||
|
/// - 버프: HP > 80% & MP > 60% & 활성 버프 없음
|
||||||
|
/// - 디버프: 몬스터 HP > 80% & 활성 디버프 없음
|
||||||
|
/// - DOT: 몬스터 HP > 60% & 활성 DOT 없음
|
||||||
|
/// - 공격: 보스전이면 강력한 스킬, 일반전이면 효율적 스킬
|
||||||
|
/// 4. MP < 20% -> 일반 공격
|
||||||
|
Skill? selectAutoSkill({
|
||||||
|
required CombatStats player,
|
||||||
|
required MonsterCombatStats monster,
|
||||||
|
required SkillSystemState skillSystem,
|
||||||
|
required List<String> availableSkillIds,
|
||||||
|
required bool Function(Skill) canUse,
|
||||||
|
List<DotEffect> activeDoTs = const [],
|
||||||
|
List<ActiveBuff> activeDebuffs = const [],
|
||||||
|
}) {
|
||||||
|
final mpRatio = player.mpRatio;
|
||||||
|
final hpRatio = player.hpRatio;
|
||||||
|
|
||||||
|
// MP 20% 미만이면 일반 공격
|
||||||
|
if (mpRatio < 0.2) return null;
|
||||||
|
|
||||||
|
// 사용 가능한 스킬 필터링
|
||||||
|
final availableSkills = availableSkillIds
|
||||||
|
.map((id) => SkillData.getSkillById(id))
|
||||||
|
.whereType<Skill>()
|
||||||
|
.where(canUse)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (availableSkills.isEmpty) return null;
|
||||||
|
|
||||||
|
// HP < 30% -> 회복 스킬 최우선 (생존)
|
||||||
|
if (hpRatio < 0.3) {
|
||||||
|
final healSkill = _findBestHealSkill(availableSkills, player.mpCurrent);
|
||||||
|
if (healSkill != null) return healSkill;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 70% 확률로 일반 공격 (스킬은 특별한 상황에서만)
|
||||||
|
final useNormalAttack = rng.nextInt(100) < 70;
|
||||||
|
if (useNormalAttack) return null;
|
||||||
|
|
||||||
|
// === 아래부터 30% 확률로 스킬 사용 ===
|
||||||
|
|
||||||
|
// 버프: HP > 80% & MP > 60% (매우 안전할 때만)
|
||||||
|
if (hpRatio > 0.8 && mpRatio > 0.6) {
|
||||||
|
final hasActiveBuff = skillSystem.activeBuffs.isNotEmpty;
|
||||||
|
if (!hasActiveBuff) {
|
||||||
|
final buffSkill = _findBestBuffSkill(availableSkills, player.mpCurrent);
|
||||||
|
if (buffSkill != null) return buffSkill;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디버프: 몬스터 HP > 80% & 활성 디버프 없음 (전투 초반)
|
||||||
|
if (monster.hpRatio > 0.8 && activeDebuffs.isEmpty) {
|
||||||
|
final debuffSkill = _findBestDebuffSkill(
|
||||||
|
availableSkills,
|
||||||
|
player.mpCurrent,
|
||||||
|
);
|
||||||
|
if (debuffSkill != null) return debuffSkill;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOT: 몬스터 HP > 60% & 활성 DOT 없음 (장기전 유리)
|
||||||
|
if (monster.hpRatio > 0.6 && activeDoTs.isEmpty) {
|
||||||
|
final dotSkill = _findBestDotSkill(availableSkills, player.mpCurrent);
|
||||||
|
if (dotSkill != null) return dotSkill;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 보스전 판단 (몬스터 레벨 20 이상 & HP 50% 이상)
|
||||||
|
final isBossFight = monster.level >= 20 && monster.hpRatio > 0.5;
|
||||||
|
|
||||||
|
if (isBossFight) {
|
||||||
|
return _findStrongestAttackSkill(availableSkills);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일반 전투 -> MP 효율 좋은 공격 스킬
|
||||||
|
return _findEfficientAttackSkill(availableSkills);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가장 좋은 DOT 스킬 찾기
|
||||||
|
Skill? _findBestDotSkill(List<Skill> skills, int currentMp) {
|
||||||
|
final dotSkills = skills
|
||||||
|
.where((s) => s.isDot && s.mpCost <= currentMp)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (dotSkills.isEmpty) return null;
|
||||||
|
|
||||||
|
dotSkills.sort((a, b) {
|
||||||
|
final aTotal =
|
||||||
|
(a.baseDotDamage ?? 0) *
|
||||||
|
((a.baseDotDurationMs ?? 0) ~/ (a.baseDotTickMs ?? 1000));
|
||||||
|
final bTotal =
|
||||||
|
(b.baseDotDamage ?? 0) *
|
||||||
|
((b.baseDotDurationMs ?? 0) ~/ (b.baseDotTickMs ?? 1000));
|
||||||
|
return bTotal.compareTo(aTotal);
|
||||||
|
});
|
||||||
|
|
||||||
|
return dotSkills.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가장 좋은 회복 스킬 찾기
|
||||||
|
Skill? _findBestHealSkill(List<Skill> skills, int currentMp) {
|
||||||
|
final healSkills = skills
|
||||||
|
.where((s) => s.isHeal && s.mpCost <= currentMp)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (healSkills.isEmpty) return null;
|
||||||
|
|
||||||
|
healSkills.sort((a, b) {
|
||||||
|
final aValue = a.healPercent * 100 + a.healAmount;
|
||||||
|
final bValue = b.healPercent * 100 + b.healAmount;
|
||||||
|
return bValue.compareTo(aValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
return healSkills.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가장 강력한 공격 스킬 찾기
|
||||||
|
Skill? _findStrongestAttackSkill(List<Skill> skills) {
|
||||||
|
final attackSkills = skills.where((s) => s.isAttack).toList();
|
||||||
|
if (attackSkills.isEmpty) return null;
|
||||||
|
|
||||||
|
attackSkills.sort(
|
||||||
|
(a, b) => b.damageMultiplier.compareTo(a.damageMultiplier),
|
||||||
|
);
|
||||||
|
return attackSkills.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MP 효율 좋은 공격 스킬 찾기
|
||||||
|
Skill? _findEfficientAttackSkill(List<Skill> skills) {
|
||||||
|
final attackSkills = skills.where((s) => s.isAttack).toList();
|
||||||
|
if (attackSkills.isEmpty) return null;
|
||||||
|
|
||||||
|
attackSkills.sort((a, b) => b.mpEfficiency.compareTo(a.mpEfficiency));
|
||||||
|
return attackSkills.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가장 좋은 버프 스킬 찾기
|
||||||
|
Skill? _findBestBuffSkill(List<Skill> skills, int currentMp) {
|
||||||
|
final buffSkills = skills
|
||||||
|
.where((s) => s.isBuff && s.mpCost <= currentMp && s.buff != null)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (buffSkills.isEmpty) return null;
|
||||||
|
|
||||||
|
buffSkills.sort((a, b) {
|
||||||
|
final aValue =
|
||||||
|
(a.buff?.atkModifier ?? 0) +
|
||||||
|
(a.buff?.defModifier ?? 0) * 0.5 +
|
||||||
|
(a.buff?.criRateModifier ?? 0) * 0.3;
|
||||||
|
final bValue =
|
||||||
|
(b.buff?.atkModifier ?? 0) +
|
||||||
|
(b.buff?.defModifier ?? 0) * 0.5 +
|
||||||
|
(b.buff?.criRateModifier ?? 0) * 0.3;
|
||||||
|
return bValue.compareTo(aValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
return buffSkills.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가장 좋은 디버프 스킬 찾기
|
||||||
|
Skill? _findBestDebuffSkill(List<Skill> skills, int currentMp) {
|
||||||
|
final debuffSkills = skills
|
||||||
|
.where((s) => s.isDebuff && s.mpCost <= currentMp && s.buff != null)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (debuffSkills.isEmpty) return null;
|
||||||
|
|
||||||
|
debuffSkills.sort((a, b) {
|
||||||
|
final aValue =
|
||||||
|
(a.buff?.atkModifier ?? 0).abs() +
|
||||||
|
(a.buff?.defModifier ?? 0).abs() * 0.5;
|
||||||
|
final bValue =
|
||||||
|
(b.buff?.atkModifier ?? 0).abs() +
|
||||||
|
(b.buff?.defModifier ?? 0).abs() * 0.5;
|
||||||
|
return bValue.compareTo(aValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
return debuffSkills.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:asciineverdie/data/skill_data.dart';
|
import 'package:asciineverdie/data/skill_data.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/skill_auto_selector.dart';
|
||||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
@@ -309,20 +310,12 @@ class SkillService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 자동 스킬 선택
|
// 자동 스킬 선택 (SkillAutoSelector에 위임)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// 전투 중 자동 스킬 선택
|
/// 전투 중 자동 스킬 선택
|
||||||
///
|
///
|
||||||
/// 우선순위:
|
/// 세부 로직은 SkillAutoSelector에 위임.
|
||||||
/// 1. HP < 30% → 회복 스킬 (최우선)
|
|
||||||
/// 2. 70% 확률로 일반 공격 (MP 절약, 기본 전투)
|
|
||||||
/// 3. 30% 확률로 스킬 사용:
|
|
||||||
/// - 버프: HP > 80% & MP > 60% & 활성 버프 없음
|
|
||||||
/// - 디버프: 몬스터 HP > 80% & 활성 디버프 없음
|
|
||||||
/// - DOT: 몬스터 HP > 60% & 활성 DOT 없음
|
|
||||||
/// - 공격: 보스전이면 강력한 스킬, 일반전이면 효율적 스킬
|
|
||||||
/// 4. MP < 20% → 일반 공격
|
|
||||||
Skill? selectAutoSkill({
|
Skill? selectAutoSkill({
|
||||||
required CombatStats player,
|
required CombatStats player,
|
||||||
required MonsterCombatStats monster,
|
required MonsterCombatStats monster,
|
||||||
@@ -331,186 +324,22 @@ class SkillService {
|
|||||||
List<DotEffect> activeDoTs = const [],
|
List<DotEffect> activeDoTs = const [],
|
||||||
List<ActiveBuff> activeDebuffs = const [],
|
List<ActiveBuff> activeDebuffs = const [],
|
||||||
}) {
|
}) {
|
||||||
final currentMp = player.mpCurrent;
|
final selector = SkillAutoSelector(rng: rng);
|
||||||
final mpRatio = player.mpRatio;
|
return selector.selectAutoSkill(
|
||||||
final hpRatio = player.hpRatio;
|
player: player,
|
||||||
|
monster: monster,
|
||||||
// MP 20% 미만이면 일반 공격
|
skillSystem: skillSystem,
|
||||||
if (mpRatio < 0.2) return null;
|
availableSkillIds: availableSkillIds,
|
||||||
|
canUse: (skill) =>
|
||||||
// 사용 가능한 스킬 필터링
|
canUseSkill(
|
||||||
final availableSkills = availableSkillIds
|
skill: skill,
|
||||||
.map((id) => SkillData.getSkillById(id))
|
currentMp: player.mpCurrent,
|
||||||
.whereType<Skill>()
|
skillSystem: skillSystem,
|
||||||
.where(
|
) ==
|
||||||
(skill) =>
|
null,
|
||||||
canUseSkill(
|
activeDoTs: activeDoTs,
|
||||||
skill: skill,
|
activeDebuffs: activeDebuffs,
|
||||||
currentMp: currentMp,
|
|
||||||
skillSystem: skillSystem,
|
|
||||||
) ==
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (availableSkills.isEmpty) return null;
|
|
||||||
|
|
||||||
// HP < 30% → 회복 스킬 최우선 (생존)
|
|
||||||
if (hpRatio < 0.3) {
|
|
||||||
final healSkill = _findBestHealSkill(availableSkills, currentMp);
|
|
||||||
if (healSkill != null) return healSkill;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 70% 확률로 일반 공격 (스킬은 특별한 상황에서만)
|
|
||||||
final useNormalAttack = rng.nextInt(100) < 70;
|
|
||||||
if (useNormalAttack) return null;
|
|
||||||
|
|
||||||
// === 아래부터 30% 확률로 스킬 사용 ===
|
|
||||||
|
|
||||||
// 버프: HP > 80% & MP > 60% (매우 안전할 때만)
|
|
||||||
// 활성 버프가 있으면 건너뜀 (중복 방지)
|
|
||||||
if (hpRatio > 0.8 && mpRatio > 0.6) {
|
|
||||||
final hasActiveBuff = skillSystem.activeBuffs.isNotEmpty;
|
|
||||||
if (!hasActiveBuff) {
|
|
||||||
final buffSkill = _findBestBuffSkill(availableSkills, currentMp);
|
|
||||||
if (buffSkill != null) return buffSkill;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 디버프: 몬스터 HP > 80% & 활성 디버프 없음 (전투 초반)
|
|
||||||
if (monster.hpRatio > 0.8 && activeDebuffs.isEmpty) {
|
|
||||||
final debuffSkill = _findBestDebuffSkill(availableSkills, currentMp);
|
|
||||||
if (debuffSkill != null) return debuffSkill;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DOT: 몬스터 HP > 60% & 활성 DOT 없음 (장기전 유리)
|
|
||||||
if (monster.hpRatio > 0.6 && activeDoTs.isEmpty) {
|
|
||||||
final dotSkill = _findBestDotSkill(availableSkills, currentMp);
|
|
||||||
if (dotSkill != null) return dotSkill;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 보스전 판단 (몬스터 레벨 20 이상 & HP 50% 이상)
|
|
||||||
final isBossFight = monster.level >= 20 && monster.hpRatio > 0.5;
|
|
||||||
|
|
||||||
if (isBossFight) {
|
|
||||||
// 가장 강력한 공격 스킬
|
|
||||||
return _findStrongestAttackSkill(availableSkills);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 일반 전투 → MP 효율 좋은 공격 스킬
|
|
||||||
return _findEfficientAttackSkill(availableSkills);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 가장 좋은 DOT 스킬 찾기
|
|
||||||
///
|
|
||||||
/// 예상 총 데미지 (틱 × 데미지) 기준으로 선택
|
|
||||||
Skill? _findBestDotSkill(List<Skill> skills, int currentMp) {
|
|
||||||
final dotSkills = skills
|
|
||||||
.where((s) => s.isDot && s.mpCost <= currentMp)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (dotSkills.isEmpty) return null;
|
|
||||||
|
|
||||||
// 예상 총 데미지 기준 정렬
|
|
||||||
dotSkills.sort((a, b) {
|
|
||||||
final aTotal =
|
|
||||||
(a.baseDotDamage ?? 0) *
|
|
||||||
((a.baseDotDurationMs ?? 0) ~/ (a.baseDotTickMs ?? 1000));
|
|
||||||
final bTotal =
|
|
||||||
(b.baseDotDamage ?? 0) *
|
|
||||||
((b.baseDotDurationMs ?? 0) ~/ (b.baseDotTickMs ?? 1000));
|
|
||||||
return bTotal.compareTo(aTotal);
|
|
||||||
});
|
|
||||||
|
|
||||||
return dotSkills.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 가장 좋은 회복 스킬 찾기
|
|
||||||
Skill? _findBestHealSkill(List<Skill> skills, int currentMp) {
|
|
||||||
final healSkills = skills
|
|
||||||
.where((s) => s.isHeal && s.mpCost <= currentMp)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (healSkills.isEmpty) return null;
|
|
||||||
|
|
||||||
// 회복량 기준 정렬 (% 회복 > 고정 회복)
|
|
||||||
healSkills.sort((a, b) {
|
|
||||||
final aValue = a.healPercent * 100 + a.healAmount;
|
|
||||||
final bValue = b.healPercent * 100 + b.healAmount;
|
|
||||||
return bValue.compareTo(aValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
return healSkills.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 가장 강력한 공격 스킬 찾기
|
|
||||||
Skill? _findStrongestAttackSkill(List<Skill> skills) {
|
|
||||||
final attackSkills = skills.where((s) => s.isAttack).toList();
|
|
||||||
if (attackSkills.isEmpty) return null;
|
|
||||||
|
|
||||||
attackSkills.sort(
|
|
||||||
(a, b) => b.damageMultiplier.compareTo(a.damageMultiplier),
|
|
||||||
);
|
);
|
||||||
return attackSkills.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// MP 효율 좋은 공격 스킬 찾기
|
|
||||||
Skill? _findEfficientAttackSkill(List<Skill> skills) {
|
|
||||||
final attackSkills = skills.where((s) => s.isAttack).toList();
|
|
||||||
if (attackSkills.isEmpty) return null;
|
|
||||||
|
|
||||||
attackSkills.sort((a, b) => b.mpEfficiency.compareTo(a.mpEfficiency));
|
|
||||||
return attackSkills.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 가장 좋은 버프 스킬 찾기
|
|
||||||
///
|
|
||||||
/// ATK 증가 버프 우선, 그 다음 복합 버프
|
|
||||||
Skill? _findBestBuffSkill(List<Skill> skills, int currentMp) {
|
|
||||||
final buffSkills = skills
|
|
||||||
.where((s) => s.isBuff && s.mpCost <= currentMp && s.buff != null)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (buffSkills.isEmpty) return null;
|
|
||||||
|
|
||||||
// ATK 증가량 기준 정렬
|
|
||||||
buffSkills.sort((a, b) {
|
|
||||||
final aValue =
|
|
||||||
(a.buff?.atkModifier ?? 0) +
|
|
||||||
(a.buff?.defModifier ?? 0) * 0.5 +
|
|
||||||
(a.buff?.criRateModifier ?? 0) * 0.3;
|
|
||||||
final bValue =
|
|
||||||
(b.buff?.atkModifier ?? 0) +
|
|
||||||
(b.buff?.defModifier ?? 0) * 0.5 +
|
|
||||||
(b.buff?.criRateModifier ?? 0) * 0.3;
|
|
||||||
return bValue.compareTo(aValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
return buffSkills.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 가장 좋은 디버프 스킬 찾기
|
|
||||||
///
|
|
||||||
/// 적 ATK 감소 디버프 우선
|
|
||||||
Skill? _findBestDebuffSkill(List<Skill> skills, int currentMp) {
|
|
||||||
final debuffSkills = skills
|
|
||||||
.where((s) => s.isDebuff && s.mpCost <= currentMp && s.buff != null)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (debuffSkills.isEmpty) return null;
|
|
||||||
|
|
||||||
// 디버프 효과 크기 기준 정렬 (음수 값이므로 절대값으로 비교)
|
|
||||||
debuffSkills.sort((a, b) {
|
|
||||||
final aValue =
|
|
||||||
(a.buff?.atkModifier ?? 0).abs() +
|
|
||||||
(a.buff?.defModifier ?? 0).abs() * 0.5;
|
|
||||||
final bValue =
|
|
||||||
(b.buff?.atkModifier ?? 0).abs() +
|
|
||||||
(b.buff?.defModifier ?? 0).abs() * 0.5;
|
|
||||||
return bValue.compareTo(aValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
return debuffSkills.first;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
259
lib/src/core/engine/task_generator.dart
Normal file
259
lib/src/core/engine/task_generator.dart
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||||
|
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/act_progression_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
||||||
|
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||||
|
|
||||||
|
/// 태스크 생성 서비스
|
||||||
|
///
|
||||||
|
/// ProgressService에서 분리된 다음 태스크 생성 로직 담당:
|
||||||
|
/// - 시장 이동, 전환 태스크, 보스 리트라이, 몬스터 전투 생성
|
||||||
|
class TaskGenerator {
|
||||||
|
const TaskGenerator({required this.config});
|
||||||
|
|
||||||
|
final PqConfig config;
|
||||||
|
|
||||||
|
/// 큐가 비어있을 때 다음 태스크 생성 (원본 Dequeue 667-684줄)
|
||||||
|
({ProgressState progress, QueueState queue}) generateNextTask(
|
||||||
|
GameState state,
|
||||||
|
) {
|
||||||
|
var progress = state.progress;
|
||||||
|
final queue = state.queue;
|
||||||
|
final oldTaskType = progress.currentTask.type;
|
||||||
|
|
||||||
|
// 1. Encumbrance 초과 시 시장 이동
|
||||||
|
if (_shouldGoToMarket(progress)) {
|
||||||
|
return _createMarketTask(progress, queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 전환 태스크 (buying/heading)
|
||||||
|
if (_needsTransitionTask(oldTaskType)) {
|
||||||
|
return _createTransitionTask(state, progress, queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Act Boss 리트라이
|
||||||
|
if (state.progress.pendingActCompletion) {
|
||||||
|
return _createActBossRetryTask(state, progress, queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 최종 보스 전투
|
||||||
|
if (state.progress.finalBossState == FinalBossState.fighting &&
|
||||||
|
!state.progress.isInBossLevelingMode) {
|
||||||
|
if (state.progress.bossLevelingEndTime != null) {
|
||||||
|
progress = progress.copyWith(clearBossLevelingEndTime: true);
|
||||||
|
}
|
||||||
|
final actProgressionService = ActProgressionService(config: config);
|
||||||
|
return actProgressionService.startFinalBossFight(state, progress, queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 일반 몬스터 전투
|
||||||
|
return _createMonsterTask(state, progress, queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 시장 이동 조건 확인
|
||||||
|
bool _shouldGoToMarket(ProgressState progress) {
|
||||||
|
return progress.encumbrance.position >= progress.encumbrance.max &&
|
||||||
|
progress.encumbrance.max > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 전환 태스크 필요 여부 확인
|
||||||
|
bool _needsTransitionTask(TaskType oldTaskType) {
|
||||||
|
return oldTaskType != TaskType.kill &&
|
||||||
|
oldTaskType != TaskType.neutral &&
|
||||||
|
oldTaskType != TaskType.buying;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 시장 이동 태스크 생성
|
||||||
|
({ProgressState progress, QueueState queue}) _createMarketTask(
|
||||||
|
ProgressState progress,
|
||||||
|
QueueState queue,
|
||||||
|
) {
|
||||||
|
final taskResult = pq_logic.startTask(
|
||||||
|
progress,
|
||||||
|
l10n.taskHeadingToMarket(),
|
||||||
|
4 * 1000,
|
||||||
|
);
|
||||||
|
final updatedProgress = taskResult.progress.copyWith(
|
||||||
|
currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.market),
|
||||||
|
currentCombat: null,
|
||||||
|
);
|
||||||
|
return (progress: updatedProgress, queue: queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 전환 태스크 생성 (buying 또는 heading)
|
||||||
|
({ProgressState progress, QueueState queue}) _createTransitionTask(
|
||||||
|
GameState state,
|
||||||
|
ProgressState progress,
|
||||||
|
QueueState queue,
|
||||||
|
) {
|
||||||
|
final gold = state.inventory.gold;
|
||||||
|
final equipPrice = state.traits.level * 50;
|
||||||
|
|
||||||
|
// Gold 충분 시 장비 구매
|
||||||
|
if (gold > equipPrice) {
|
||||||
|
final taskResult = pq_logic.startTask(
|
||||||
|
progress,
|
||||||
|
l10n.taskUpgradingHardware(),
|
||||||
|
5 * 1000,
|
||||||
|
);
|
||||||
|
final updatedProgress = taskResult.progress.copyWith(
|
||||||
|
currentTask: TaskInfo(
|
||||||
|
caption: taskResult.caption,
|
||||||
|
type: TaskType.buying,
|
||||||
|
),
|
||||||
|
currentCombat: null,
|
||||||
|
);
|
||||||
|
return (progress: updatedProgress, queue: queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gold 부족 시 전장 이동
|
||||||
|
final taskResult = pq_logic.startTask(
|
||||||
|
progress,
|
||||||
|
l10n.taskEnteringDebugZone(),
|
||||||
|
4 * 1000,
|
||||||
|
);
|
||||||
|
final updatedProgress = taskResult.progress.copyWith(
|
||||||
|
currentTask: TaskInfo(
|
||||||
|
caption: taskResult.caption,
|
||||||
|
type: TaskType.neutral,
|
||||||
|
),
|
||||||
|
currentCombat: null,
|
||||||
|
);
|
||||||
|
return (progress: updatedProgress, queue: queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Act Boss 재도전 태스크 생성
|
||||||
|
({ProgressState progress, QueueState queue}) _createActBossRetryTask(
|
||||||
|
GameState state,
|
||||||
|
ProgressState progress,
|
||||||
|
QueueState queue,
|
||||||
|
) {
|
||||||
|
final actProgressionService = ActProgressionService(config: config);
|
||||||
|
final actBoss = actProgressionService.createActBoss(state);
|
||||||
|
final combatCalculator = CombatCalculator(rng: state.rng);
|
||||||
|
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
||||||
|
player: actBoss.playerStats,
|
||||||
|
monster: actBoss.monsterStats,
|
||||||
|
);
|
||||||
|
|
||||||
|
final taskResult = pq_logic.startTask(
|
||||||
|
progress,
|
||||||
|
l10n.taskDebugging(actBoss.monsterStats.name),
|
||||||
|
durationMillis,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedProgress = taskResult.progress.copyWith(
|
||||||
|
currentTask: TaskInfo(
|
||||||
|
caption: taskResult.caption,
|
||||||
|
type: TaskType.kill,
|
||||||
|
monsterBaseName: actBoss.monsterStats.name,
|
||||||
|
monsterPart: '*',
|
||||||
|
monsterLevel: actBoss.monsterStats.level,
|
||||||
|
monsterGrade: MonsterGrade.boss,
|
||||||
|
monsterSize: getBossSizeForAct(state.progress.plotStageCount),
|
||||||
|
),
|
||||||
|
currentCombat: actBoss,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (progress: updatedProgress, queue: queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 일반 몬스터 전투 태스크 생성
|
||||||
|
({ProgressState progress, QueueState queue}) _createMonsterTask(
|
||||||
|
GameState state,
|
||||||
|
ProgressState progress,
|
||||||
|
QueueState queue,
|
||||||
|
) {
|
||||||
|
final level = state.traits.level;
|
||||||
|
|
||||||
|
// 퀘스트 몬스터 데이터 확인
|
||||||
|
final questMonster = state.progress.currentQuestMonster;
|
||||||
|
final questMonsterData = questMonster?.monsterData;
|
||||||
|
final questLevel = questMonsterData != null
|
||||||
|
? int.tryParse(questMonsterData.split('|').elementAtOrNull(1) ?? '') ??
|
||||||
|
0
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 몬스터 생성
|
||||||
|
final monsterResult = pq_logic.monsterTask(
|
||||||
|
config,
|
||||||
|
state.rng,
|
||||||
|
level,
|
||||||
|
questMonsterData,
|
||||||
|
questLevel,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 몬스터 레벨 조정 (밸런스)
|
||||||
|
final actMinLevel = ActMonsterLevel.forPlotStage(
|
||||||
|
state.progress.plotStageCount,
|
||||||
|
);
|
||||||
|
final baseLevel = math.max(level, actMinLevel);
|
||||||
|
final effectiveMonsterLevel = monsterResult.level
|
||||||
|
.clamp(math.max(1, baseLevel - 3), baseLevel + 3)
|
||||||
|
.toInt();
|
||||||
|
|
||||||
|
// 전투 스탯 생성
|
||||||
|
final playerCombatStats = CombatStats.fromStats(
|
||||||
|
stats: state.stats,
|
||||||
|
equipment: state.equipment,
|
||||||
|
level: level,
|
||||||
|
monsterLevel: effectiveMonsterLevel,
|
||||||
|
);
|
||||||
|
|
||||||
|
final monsterCombatStats = MonsterCombatStats.fromLevel(
|
||||||
|
name: monsterResult.displayName,
|
||||||
|
level: effectiveMonsterLevel,
|
||||||
|
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
|
||||||
|
plotStageCount: state.progress.plotStageCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 전투 상태 및 지속시간
|
||||||
|
final combatState = CombatState.start(
|
||||||
|
playerStats: playerCombatStats,
|
||||||
|
monsterStats: monsterCombatStats,
|
||||||
|
);
|
||||||
|
|
||||||
|
final combatCalculator = CombatCalculator(rng: state.rng);
|
||||||
|
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
||||||
|
player: playerCombatStats,
|
||||||
|
monster: monsterCombatStats,
|
||||||
|
);
|
||||||
|
|
||||||
|
final taskResult = pq_logic.startTask(
|
||||||
|
progress,
|
||||||
|
l10n.taskDebugging(monsterResult.displayName),
|
||||||
|
durationMillis,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 몬스터 사이즈 결정
|
||||||
|
final monsterSize = getMonsterSizeForAct(
|
||||||
|
plotStageCount: state.progress.plotStageCount,
|
||||||
|
grade: monsterResult.grade,
|
||||||
|
rng: state.rng,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedProgress = taskResult.progress.copyWith(
|
||||||
|
currentTask: TaskInfo(
|
||||||
|
caption: taskResult.caption,
|
||||||
|
type: TaskType.kill,
|
||||||
|
monsterBaseName: monsterResult.baseName,
|
||||||
|
monsterPart: monsterResult.part,
|
||||||
|
monsterLevel: effectiveMonsterLevel,
|
||||||
|
monsterGrade: monsterResult.grade,
|
||||||
|
monsterSize: monsterSize,
|
||||||
|
),
|
||||||
|
currentCombat: combatState,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (progress: updatedProgress, queue: queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/src/core/di/i_ad_service.dart';
|
||||||
|
import 'package:asciineverdie/src/core/infrastructure/iap_service.dart';
|
||||||
|
|
||||||
/// 광고 타입
|
/// 광고 타입
|
||||||
enum AdType {
|
enum AdType {
|
||||||
@@ -20,6 +23,9 @@ enum AdType {
|
|||||||
|
|
||||||
/// 속도업용 인터스티셜 광고 (6초)
|
/// 속도업용 인터스티셜 광고 (6초)
|
||||||
interstitialSpeed,
|
interstitialSpeed,
|
||||||
|
|
||||||
|
/// 복귀 보상용 리워드 광고 (30초)
|
||||||
|
rewardReturn,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 광고 결과
|
/// 광고 결과
|
||||||
@@ -41,16 +47,14 @@ enum AdResult {
|
|||||||
///
|
///
|
||||||
/// AdMob 리워드/인터스티셜 광고 로드 및 표시를 관리합니다.
|
/// AdMob 리워드/인터스티셜 광고 로드 및 표시를 관리합니다.
|
||||||
/// 디버그 모드에서는 광고 ON/OFF 토글이 가능합니다.
|
/// 디버그 모드에서는 광고 ON/OFF 토글이 가능합니다.
|
||||||
class AdService {
|
class AdService implements IAdService {
|
||||||
AdService._();
|
AdService._();
|
||||||
|
|
||||||
static AdService? _instance;
|
/// GetIt 등록용 팩토리 메서드
|
||||||
|
factory AdService.createInstance() => AdService._();
|
||||||
|
|
||||||
/// 싱글톤 인스턴스
|
/// 싱글톤 인스턴스 (GetIt 서비스 로케이터에 위임)
|
||||||
static AdService get instance {
|
static IAdService get instance => GetIt.instance<IAdService>();
|
||||||
_instance ??= AdService._();
|
|
||||||
return _instance!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// 광고 단위 ID
|
// 광고 단위 ID
|
||||||
@@ -71,15 +75,14 @@ class AdService {
|
|||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// 프로덕션 광고 ID (AdMob 콘솔에서 생성 후 교체)
|
// 프로덕션 광고 ID (AdMob 콘솔에서 생성 후 교체)
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// TODO: AdMob 콘솔에서 광고 단위 생성 후 아래 ID 교체
|
|
||||||
static const String _prodRewardedAndroid =
|
static const String _prodRewardedAndroid =
|
||||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 리워드 광고
|
'ca-app-pub-6691216385521068/3457464395'; // Android 리워드 광고
|
||||||
static const String _prodRewardedIos =
|
// TODO(ios): AdMob iOS 광고 ID — iOS 출시 전 필수 교체
|
||||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 리워드 광고
|
static const String _prodRewardedIos = _testRewardedIos;
|
||||||
static const String _prodInterstitialAndroid =
|
static const String _prodInterstitialAndroid =
|
||||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 인터스티셜 광고
|
'ca-app-pub-6691216385521068/1625507977'; // Android 인터스티셜 광고
|
||||||
static const String _prodInterstitialIos =
|
// TODO(ios): AdMob iOS 광고 ID — iOS 출시 전 필수 교체
|
||||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 인터스티셜 광고
|
static const String _prodInterstitialIos = _testInterstitialIos;
|
||||||
|
|
||||||
/// 리워드 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID)
|
/// 리워드 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID)
|
||||||
String get _rewardAdUnitId {
|
String get _rewardAdUnitId {
|
||||||
@@ -124,6 +127,7 @@ class AdService {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
/// AdMob SDK 초기화
|
/// AdMob SDK 초기화
|
||||||
|
@override
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (_isInitialized) return;
|
if (_isInitialized) return;
|
||||||
|
|
||||||
@@ -195,6 +199,7 @@ class AdService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 리워드 광고 준비 여부
|
/// 리워드 광고 준비 여부
|
||||||
|
@override
|
||||||
bool get isRewardedAdReady => _rewardedAd != null || _shouldSkipAd;
|
bool get isRewardedAdReady => _rewardedAd != null || _shouldSkipAd;
|
||||||
|
|
||||||
/// 리워드 광고 표시
|
/// 리워드 광고 표시
|
||||||
@@ -202,6 +207,7 @@ class AdService {
|
|||||||
/// [adType] 광고 타입 (로깅용)
|
/// [adType] 광고 타입 (로깅용)
|
||||||
/// [onRewarded] 보상 지급 콜백
|
/// [onRewarded] 보상 지급 콜백
|
||||||
/// Returns: 광고 결과
|
/// Returns: 광고 결과
|
||||||
|
@override
|
||||||
Future<AdResult> showRewardedAd({
|
Future<AdResult> showRewardedAd({
|
||||||
required AdType adType,
|
required AdType adType,
|
||||||
required void Function() onRewarded,
|
required void Function() onRewarded,
|
||||||
@@ -306,6 +312,7 @@ class AdService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 인터스티셜 광고 준비 여부
|
/// 인터스티셜 광고 준비 여부
|
||||||
|
@override
|
||||||
bool get isInterstitialAdReady => _interstitialAd != null || _shouldSkipAd;
|
bool get isInterstitialAdReady => _interstitialAd != null || _shouldSkipAd;
|
||||||
|
|
||||||
/// 인터스티셜 광고 표시
|
/// 인터스티셜 광고 표시
|
||||||
@@ -313,6 +320,7 @@ class AdService {
|
|||||||
/// [adType] 광고 타입 (로깅용)
|
/// [adType] 광고 타입 (로깅용)
|
||||||
/// [onComplete] 광고 완료 콜백 (보상 지급)
|
/// [onComplete] 광고 완료 콜백 (보상 지급)
|
||||||
/// Returns: 광고 결과
|
/// Returns: 광고 결과
|
||||||
|
@override
|
||||||
Future<AdResult> showInterstitialAd({
|
Future<AdResult> showInterstitialAd({
|
||||||
required AdType adType,
|
required AdType adType,
|
||||||
required void Function() onComplete,
|
required void Function() onComplete,
|
||||||
@@ -365,6 +373,7 @@ class AdService {
|
|||||||
overlays: SystemUiOverlay.values,
|
overlays: SystemUiOverlay.values,
|
||||||
);
|
);
|
||||||
ad.dispose();
|
ad.dispose();
|
||||||
|
onComplete();
|
||||||
_loadInterstitialAd();
|
_loadInterstitialAd();
|
||||||
if (!completer.isCompleted) {
|
if (!completer.isCompleted) {
|
||||||
completer.complete(AdResult.failed);
|
completer.complete(AdResult.failed);
|
||||||
@@ -383,6 +392,7 @@ class AdService {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
/// 리소스 해제
|
/// 리소스 해제
|
||||||
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_rewardedAd?.dispose();
|
_rewardedAd?.dispose();
|
||||||
_rewardedAd = null;
|
_rewardedAd = null;
|
||||||
@@ -1,19 +1,26 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:pointycastle/export.dart' as pc;
|
||||||
|
import 'package:pointycastle/asn1/asn1_parser.dart';
|
||||||
|
import 'package:pointycastle/asn1/primitives/asn1_bit_string.dart';
|
||||||
|
import 'package:pointycastle/asn1/primitives/asn1_integer.dart';
|
||||||
|
import 'package:pointycastle/asn1/primitives/asn1_sequence.dart';
|
||||||
|
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||||
|
import 'package:asciineverdie/src/core/di/i_iap_service.dart';
|
||||||
|
|
||||||
/// IAP 상품 ID
|
/// IAP 상품 ID
|
||||||
class IAPProductIds {
|
class IAPProductIds {
|
||||||
IAPProductIds._();
|
IAPProductIds._();
|
||||||
|
|
||||||
/// 광고 제거 상품 ID (비소모성)
|
/// 광고 제거 상품 ID (비소모성)
|
||||||
/// TODO: Google Play Console / App Store Connect에서 상품 생성 후 ID 교체
|
static const String removeAds = 'remove_ads_and';
|
||||||
static const String removeAds = 'remove_ads';
|
|
||||||
|
|
||||||
/// 모든 상품 ID 목록
|
/// 모든 상품 ID 목록
|
||||||
static const Set<String> all = {removeAds};
|
static const Set<String> all = {removeAds};
|
||||||
@@ -43,20 +50,27 @@ enum IAPResult {
|
|||||||
debugSimulated,
|
debugSimulated,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Google Play 공개키 (public key)
|
||||||
|
///
|
||||||
|
/// Google Play Console > 수익 창출(Monetization) 설정 > 라이선스(Licensing)에서
|
||||||
|
/// Base64 인코딩된 RSA 공개키를 복사하여 아래에 붙여넣기.
|
||||||
|
/// 공개키이므로 비밀이 아니지만, 바이너리 스캔 방지를 위해 분할 저장.
|
||||||
|
const _gpKeyPart1 = 'YOUR_GOOGLE_PLAY_';
|
||||||
|
const _gpKeyPart2 = 'PUBLIC_KEY_HERE';
|
||||||
|
String get _googlePlayPublicKey => _gpKeyPart1 + _gpKeyPart2;
|
||||||
|
|
||||||
/// IAP 서비스
|
/// IAP 서비스
|
||||||
///
|
///
|
||||||
/// 인앱 구매 (광고 제거) 처리를 담당합니다.
|
/// 인앱 구매 (광고 제거) 처리를 담당합니다.
|
||||||
/// shared_preferences를 사용하여 구매 상태를 영구 저장합니다.
|
/// flutter_secure_storage를 사용하여 구매 상태를 보안 저장합니다.
|
||||||
class IAPService {
|
class IAPService implements IIAPService {
|
||||||
IAPService._();
|
IAPService._();
|
||||||
|
|
||||||
static IAPService? _instance;
|
/// GetIt 등록용 팩토리 메서드
|
||||||
|
factory IAPService.createInstance() => IAPService._();
|
||||||
|
|
||||||
/// 싱글톤 인스턴스
|
/// 싱글톤 인스턴스 (GetIt 서비스 로케이터에 위임)
|
||||||
static IAPService get instance {
|
static IIAPService get instance => GetIt.instance<IIAPService>();
|
||||||
_instance ??= IAPService._();
|
|
||||||
return _instance!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// 상수
|
// 상수
|
||||||
@@ -65,11 +79,14 @@ class IAPService {
|
|||||||
/// 구매 상태 저장 키
|
/// 구매 상태 저장 키
|
||||||
static const String _purchaseKey = 'iap_remove_ads_purchased';
|
static const String _purchaseKey = 'iap_remove_ads_purchased';
|
||||||
|
|
||||||
|
/// 보안 저장소 (secure storage) 인스턴스
|
||||||
|
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// 상태
|
// 상태
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
final InAppPurchase _iap = InAppPurchase.instance;
|
late final InAppPurchase _iap = InAppPurchase.instance;
|
||||||
|
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool _isAvailable = false;
|
bool _isAvailable = false;
|
||||||
@@ -91,6 +108,7 @@ class IAPService {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
/// IAP 서비스 초기화
|
/// IAP 서비스 초기화
|
||||||
|
@override
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (_isInitialized) return;
|
if (_isInitialized) return;
|
||||||
|
|
||||||
@@ -124,6 +142,12 @@ class IAPService {
|
|||||||
|
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
debugPrint('[IAPService] Initialized');
|
debugPrint('[IAPService] Initialized');
|
||||||
|
|
||||||
|
// 로컬에 구매 기록이 없으면 스토어에서 자동 복원 시도
|
||||||
|
// (앱 삭제 후 재설치 대응, 비동기로 실행하여 초기화 블로킹 없음)
|
||||||
|
if (!_adRemovalPurchased) {
|
||||||
|
_tryAutoRestore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 상품 정보 로드
|
/// 상품 정보 로드
|
||||||
@@ -131,9 +155,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) {
|
||||||
@@ -146,17 +168,27 @@ class IAPService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 저장된 구매 상태 로드
|
/// 앱 시작 시 자동 구매 복원 (재설치 대응)
|
||||||
|
///
|
||||||
|
/// purchaseStream 구독 후 호출되므로, 복원된 구매는
|
||||||
|
/// _onPurchaseUpdate → _handleSuccessfulPurchase로 처리됨.
|
||||||
|
void _tryAutoRestore() {
|
||||||
|
debugPrint('[IAPService] Attempting auto-restore...');
|
||||||
|
_iap.restorePurchases().catchError((Object error) {
|
||||||
|
debugPrint('[IAPService] Auto-restore failed: $error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 저장된 구매 상태 로드 (보안 저장소 사용)
|
||||||
Future<void> _loadPurchaseState() async {
|
Future<void> _loadPurchaseState() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final value = await _secureStorage.read(key: _purchaseKey);
|
||||||
_adRemovalPurchased = prefs.getBool(_purchaseKey) ?? false;
|
_adRemovalPurchased = value == 'true';
|
||||||
debugPrint('[IAPService] Loaded purchase state: $_adRemovalPurchased');
|
debugPrint('[IAPService] Loaded purchase state: $_adRemovalPurchased');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 구매 상태 저장
|
/// 구매 상태 저장 (보안 저장소 사용)
|
||||||
Future<void> _savePurchaseState(bool purchased) async {
|
Future<void> _savePurchaseState(bool purchased) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await _secureStorage.write(key: _purchaseKey, value: purchased.toString());
|
||||||
await prefs.setBool(_purchaseKey, purchased);
|
|
||||||
_adRemovalPurchased = purchased;
|
_adRemovalPurchased = purchased;
|
||||||
debugPrint('[IAPService] Saved purchase state: $purchased');
|
debugPrint('[IAPService] Saved purchase state: $purchased');
|
||||||
}
|
}
|
||||||
@@ -166,9 +198,11 @@ class IAPService {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
/// 디버그 모드 IAP 시뮬레이션 활성화 여부
|
/// 디버그 모드 IAP 시뮬레이션 활성화 여부
|
||||||
|
@override
|
||||||
bool get debugIAPSimulated => _debugIAPSimulated;
|
bool get debugIAPSimulated => _debugIAPSimulated;
|
||||||
|
|
||||||
/// 디버그 모드 IAP 시뮬레이션 토글
|
/// 디버그 모드 IAP 시뮬레이션 토글
|
||||||
|
@override
|
||||||
set debugIAPSimulated(bool value) {
|
set debugIAPSimulated(bool value) {
|
||||||
_debugIAPSimulated = value;
|
_debugIAPSimulated = value;
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
@@ -182,18 +216,21 @@ class IAPService {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
/// 광고 제거 구매 여부
|
/// 광고 제거 구매 여부
|
||||||
|
@override
|
||||||
bool get isAdRemovalPurchased {
|
bool get isAdRemovalPurchased {
|
||||||
if (kDebugMode && _debugIAPSimulated) return true;
|
if (kDebugMode && _debugIAPSimulated) return true;
|
||||||
return _adRemovalPurchased;
|
return _adRemovalPurchased;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 스토어 가용성
|
/// 스토어 가용성
|
||||||
|
@override
|
||||||
bool get isStoreAvailable => _isAvailable;
|
bool get isStoreAvailable => _isAvailable;
|
||||||
|
|
||||||
/// 광고 제거 상품 정보
|
/// 광고 제거 상품 정보
|
||||||
ProductDetails? get removeAdsProduct => _removeAdsProduct;
|
ProductDetails? get removeAdsProduct => _removeAdsProduct;
|
||||||
|
|
||||||
/// 광고 제거 상품 가격 문자열
|
/// 광고 제거 상품 가격 문자열
|
||||||
|
@override
|
||||||
String get removeAdsPrice {
|
String get removeAdsPrice {
|
||||||
if (_removeAdsProduct != null) {
|
if (_removeAdsProduct != null) {
|
||||||
return _removeAdsProduct!.price;
|
return _removeAdsProduct!.price;
|
||||||
@@ -211,6 +248,7 @@ class IAPService {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
/// 광고 제거 구매
|
/// 광고 제거 구매
|
||||||
|
@override
|
||||||
Future<IAPResult> purchaseRemoveAds() async {
|
Future<IAPResult> purchaseRemoveAds() async {
|
||||||
// 디버그 모드 시뮬레이션
|
// 디버그 모드 시뮬레이션
|
||||||
if (kDebugMode && _debugIAPSimulated) {
|
if (kDebugMode && _debugIAPSimulated) {
|
||||||
@@ -238,14 +276,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) {
|
||||||
@@ -259,6 +293,7 @@ class IAPService {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
/// 구매 복원
|
/// 구매 복원
|
||||||
|
@override
|
||||||
Future<IAPResult> restorePurchases() async {
|
Future<IAPResult> restorePurchases() async {
|
||||||
// 디버그 모드 시뮬레이션
|
// 디버그 모드 시뮬레이션
|
||||||
if (kDebugMode && _debugIAPSimulated) {
|
if (kDebugMode && _debugIAPSimulated) {
|
||||||
@@ -318,18 +353,128 @@ class IAPService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 구매 성공 처리
|
/// 구매 성공 처리 (서명 검증 포함)
|
||||||
Future<void> _handleSuccessfulPurchase(PurchaseDetails purchase) async {
|
Future<void> _handleSuccessfulPurchase(PurchaseDetails purchase) async {
|
||||||
if (purchase.productID == IAPProductIds.removeAds) {
|
if (purchase.productID == IAPProductIds.removeAds) {
|
||||||
// 구매 검증 (서버 검증 생략, 로컬 저장)
|
// 플랫폼별 로컬 영수증 검증 (local receipt verification)
|
||||||
|
final verified = _verifyPurchaseLocally(purchase);
|
||||||
|
if (!verified) {
|
||||||
|
debugPrint('[IAPService] Purchase verification FAILED - not granting');
|
||||||
|
await _completePurchase(purchase);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _savePurchaseState(true);
|
await _savePurchaseState(true);
|
||||||
debugPrint('[IAPService] Ad removal purchased successfully');
|
debugPrint('[IAPService] Ad removal purchased & verified successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 구매 완료 처리
|
// 구매 완료 처리
|
||||||
await _completePurchase(purchase);
|
await _completePurchase(purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 로컬 영수증 검증 (local receipt verification)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// 플랫폼별 로컬 구매 검증
|
||||||
|
///
|
||||||
|
/// Google Play: RSASSA-PKCS1-v1_5 + SHA1 서명 검증
|
||||||
|
/// Apple: StoreKit 2 플러그인 자체 검증 (향후 JWS 로컬 검증 추가 예정)
|
||||||
|
bool _verifyPurchaseLocally(PurchaseDetails purchase) {
|
||||||
|
final source = purchase.verificationData.source;
|
||||||
|
|
||||||
|
if (source == 'google_play') {
|
||||||
|
return _verifyGooglePlayPurchase(purchase);
|
||||||
|
} else if (source == 'app_store') {
|
||||||
|
// TODO: Apple StoreKit 2 JWS 로컬 검증 추가
|
||||||
|
// 현재는 in_app_purchase 플러그인의 네이티브 검증에 의존
|
||||||
|
debugPrint('[IAPService] Apple purchase - plugin-native verification');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[IAPService] Unknown source: $source - skipping verification');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Google Play 구매 RSA 서명 검증
|
||||||
|
///
|
||||||
|
/// localVerificationData는 Base64 인코딩된 서명 JSON 데이터.
|
||||||
|
/// serverVerificationData에 원본 구매 JSON이 포함됨.
|
||||||
|
bool _verifyGooglePlayPurchase(PurchaseDetails purchase) {
|
||||||
|
try {
|
||||||
|
final signedData = purchase.verificationData.serverVerificationData;
|
||||||
|
final signature = purchase.verificationData.localVerificationData;
|
||||||
|
|
||||||
|
if (signedData.isEmpty || signature.isEmpty) {
|
||||||
|
debugPrint('[IAPService] Empty verification data');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _verifyRsaSignature(signedData, signature, _googlePlayPublicKey);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IAPService] Google Play verification error: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RSASSA-PKCS1-v1_5 + SHA1 서명 검증
|
||||||
|
///
|
||||||
|
/// Google Play 서명 검증에 사용되는 RSA 알고리즘.
|
||||||
|
/// [data]: 서명된 원본 데이터 (signed data)
|
||||||
|
/// [signatureBase64]: Base64 인코딩된 서명 (signature)
|
||||||
|
/// [publicKeyBase64]: Base64 인코딩된 DER 공개키 (public key)
|
||||||
|
bool _verifyRsaSignature(
|
||||||
|
String data,
|
||||||
|
String signatureBase64,
|
||||||
|
String publicKeyBase64,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// 공개키 DER 바이트 디코딩 (decode public key)
|
||||||
|
final publicKeyBytes = base64Decode(publicKeyBase64);
|
||||||
|
final publicKey = _parsePublicKeyFromDer(publicKeyBytes);
|
||||||
|
|
||||||
|
// 서명 디코딩 (decode signature)
|
||||||
|
final signatureBytes = base64Decode(signatureBase64);
|
||||||
|
|
||||||
|
// 원본 데이터를 UTF-8 바이트로 변환
|
||||||
|
final dataBytes = Uint8List.fromList(utf8.encode(data));
|
||||||
|
|
||||||
|
// RSASSA-PKCS1-v1_5 + SHA1 검증
|
||||||
|
final signer = pc.Signer('SHA-1/RSA');
|
||||||
|
signer.init(
|
||||||
|
false, // verify 모드
|
||||||
|
pc.PublicKeyParameter<pc.RSAPublicKey>(publicKey),
|
||||||
|
);
|
||||||
|
|
||||||
|
return signer.verifySignature(dataBytes, pc.RSASignature(signatureBytes));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[IAPService] RSA verification error: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DER 인코딩된 공개키 파싱 (parse DER-encoded public key)
|
||||||
|
///
|
||||||
|
/// X.509 SubjectPublicKeyInfo 형식의 DER 바이트에서 RSA 공개키 추출.
|
||||||
|
pc.RSAPublicKey _parsePublicKeyFromDer(Uint8List bytes) {
|
||||||
|
// ASN.1 시퀀스 파서 (ASN.1 sequence parser)
|
||||||
|
final asn1Parser = ASN1Parser(bytes);
|
||||||
|
final topSequence = asn1Parser.nextObject() as ASN1Sequence;
|
||||||
|
|
||||||
|
// SubjectPublicKeyInfo 내부의 BitString에서 공개키 추출
|
||||||
|
final bitString = topSequence.elements![1] as ASN1BitString;
|
||||||
|
final publicKeyBytes = bitString.stringValues as Uint8List;
|
||||||
|
|
||||||
|
// RSA 공개키 시퀀스 파싱
|
||||||
|
final rsaParser = ASN1Parser(publicKeyBytes);
|
||||||
|
final rsaSequence = rsaParser.nextObject() as ASN1Sequence;
|
||||||
|
|
||||||
|
final modulus = (rsaSequence.elements![0] as ASN1Integer).integer!;
|
||||||
|
final exponent = (rsaSequence.elements![1] as ASN1Integer).integer!;
|
||||||
|
|
||||||
|
return pc.RSAPublicKey(modulus, exponent);
|
||||||
|
}
|
||||||
|
|
||||||
/// 구매 완료 처리 (필수)
|
/// 구매 완료 처리 (필수)
|
||||||
Future<void> _completePurchase(PurchaseDetails purchase) async {
|
Future<void> _completePurchase(PurchaseDetails purchase) async {
|
||||||
if (purchase.pendingCompletePurchase) {
|
if (purchase.pendingCompletePurchase) {
|
||||||
@@ -343,6 +488,7 @@ class IAPService {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
/// 리소스 해제
|
/// 리소스 해제
|
||||||
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_subscription?.cancel();
|
_subscription?.cancel();
|
||||||
_subscription = null;
|
_subscription = null;
|
||||||
147
lib/src/core/logging/error_logger.dart
Normal file
147
lib/src/core/logging/error_logger.dart
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// 로컬 파일 기반 에러 로거(error logger)
|
||||||
|
///
|
||||||
|
/// 네트워크 없이 에러/크래시를 로컬 파일에 기록합니다.
|
||||||
|
/// 로그 파일 크기가 [maxLogBytes]를 초과하면 자동 로테이션(rotation)합니다.
|
||||||
|
class ErrorLogger {
|
||||||
|
ErrorLogger._();
|
||||||
|
|
||||||
|
static final ErrorLogger instance = ErrorLogger._();
|
||||||
|
|
||||||
|
/// 최대 로그 파일 크기 (1MB)
|
||||||
|
static const int maxLogBytes = 1024 * 1024;
|
||||||
|
|
||||||
|
/// 로테이션 시 보관할 백업 파일 수
|
||||||
|
static const int maxBackupCount = 2;
|
||||||
|
|
||||||
|
static const String _logFileName = 'error_log.jsonl';
|
||||||
|
|
||||||
|
String? _appVersion;
|
||||||
|
File? _logFile;
|
||||||
|
|
||||||
|
/// 초기화(initialization) — 앱 시작 시 한 번 호출
|
||||||
|
Future<void> init() async {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
_logFile = File('${dir.path}/$_logFileName');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final info = await PackageInfo.fromPlatform();
|
||||||
|
_appVersion = '${info.version}+${info.buildNumber}';
|
||||||
|
} catch (_) {
|
||||||
|
_appVersion = 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 에러 기록(log error)
|
||||||
|
///
|
||||||
|
/// [error] 에러 객체, [stackTrace] 스택 트레이스(stack trace),
|
||||||
|
/// [context] 추가 맥락 정보(optional).
|
||||||
|
Future<void> log(
|
||||||
|
Object error, {
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
String? context,
|
||||||
|
}) async {
|
||||||
|
final file = _logFile;
|
||||||
|
if (file == null) return;
|
||||||
|
|
||||||
|
final entry = <String, dynamic>{
|
||||||
|
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||||
|
'error': error.toString(),
|
||||||
|
'stackTrace': stackTrace?.toString(),
|
||||||
|
'appVersion': _appVersion,
|
||||||
|
'context': context,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
final line = '${jsonEncode(entry)}\n';
|
||||||
|
await file.writeAsString(line, mode: FileMode.append, flush: true);
|
||||||
|
await _rotateIfNeeded();
|
||||||
|
} catch (e) {
|
||||||
|
// 로깅 실패 시 디버그 콘솔에만 출력
|
||||||
|
debugPrint('[ErrorLogger] 로그 기록 실패: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 최근 에러 목록 조회(recent errors)
|
||||||
|
///
|
||||||
|
/// 최신순으로 최대 [count]개 반환합니다.
|
||||||
|
Future<List<Map<String, dynamic>>> recentErrors({int count = 20}) async {
|
||||||
|
final file = _logFile;
|
||||||
|
if (file == null || !file.existsSync()) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final lines = await file.readAsLines();
|
||||||
|
final entries = <Map<String, dynamic>>[];
|
||||||
|
// 역순(최신 먼저)으로 파싱
|
||||||
|
for (var i = lines.length - 1; i >= 0 && entries.length < count; i--) {
|
||||||
|
final line = lines[i].trim();
|
||||||
|
if (line.isEmpty) continue;
|
||||||
|
try {
|
||||||
|
entries.add(jsonDecode(line) as Map<String, dynamic>);
|
||||||
|
} catch (_) {
|
||||||
|
// 손상된 라인(corrupted line) 건너뜀
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[ErrorLogger] 로그 읽기 실패: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 로그 파일 내보내기(export) — 사용자 문의 시 첨부용
|
||||||
|
///
|
||||||
|
/// 전체 로그 내용을 문자열로 반환합니다.
|
||||||
|
Future<String> export() async {
|
||||||
|
final file = _logFile;
|
||||||
|
if (file == null || !file.existsSync()) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await file.readAsString();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[ErrorLogger] 로그 내보내기 실패: $e');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 로그 파일 경로 반환
|
||||||
|
String? get logFilePath => _logFile?.path;
|
||||||
|
|
||||||
|
/// 로그 파일 크기 초과 시 로테이션(rotation) 수행
|
||||||
|
Future<void> _rotateIfNeeded() async {
|
||||||
|
final file = _logFile;
|
||||||
|
if (file == null || !file.existsSync()) return;
|
||||||
|
|
||||||
|
final size = await file.length();
|
||||||
|
if (size <= maxLogBytes) return;
|
||||||
|
|
||||||
|
final dir = file.parent.path;
|
||||||
|
final baseName = _logFileName.replaceAll('.jsonl', '');
|
||||||
|
|
||||||
|
// 가장 오래된 백업 삭제
|
||||||
|
final oldest = File('$dir/$baseName.$maxBackupCount.jsonl');
|
||||||
|
if (oldest.existsSync()) {
|
||||||
|
await oldest.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 백업 번호 증가(shift)
|
||||||
|
for (var i = maxBackupCount - 1; i >= 1; i--) {
|
||||||
|
final src = File('$dir/$baseName.$i.jsonl');
|
||||||
|
if (src.existsSync()) {
|
||||||
|
await src.rename('$dir/$baseName.${i + 1}.jsonl');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 로그를 .1 백업으로 이동
|
||||||
|
await file.rename('$dir/$baseName.1.jsonl');
|
||||||
|
|
||||||
|
// 새 빈 로그 파일 생성
|
||||||
|
_logFile = File('$dir/$_logFileName');
|
||||||
|
}
|
||||||
|
}
|
||||||
40
lib/src/core/logging/error_logger_zone.dart
Normal file
40
lib/src/core/logging/error_logger_zone.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/src/core/logging/error_logger.dart';
|
||||||
|
|
||||||
|
/// 에러 핸들링 존(error handling zone) 설정
|
||||||
|
///
|
||||||
|
/// [FlutterError.onError]와 [runZonedGuarded]를 조합하여
|
||||||
|
/// 앱 전체의 미처리 에러(uncaught error)를 [ErrorLogger]에 기록합니다.
|
||||||
|
Future<void> setupErrorHandling(Future<void> Function() appRunner) async {
|
||||||
|
// 위젯 바인딩(widget binding) 초기화
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// 에러 로거 초기화
|
||||||
|
await ErrorLogger.instance.init();
|
||||||
|
|
||||||
|
// Flutter 프레임워크 에러(framework error) 핸들러
|
||||||
|
FlutterError.onError = (FlutterErrorDetails details) {
|
||||||
|
// 기본 핸들러(콘솔 출력) 유지
|
||||||
|
FlutterError.presentError(details);
|
||||||
|
|
||||||
|
ErrorLogger.instance.log(
|
||||||
|
details.exception,
|
||||||
|
stackTrace: details.stack,
|
||||||
|
context: details.context?.toString(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 비동기 에러(async error) 포함 전체 존 가드(zone guard)
|
||||||
|
runZonedGuarded(
|
||||||
|
() async {
|
||||||
|
await appRunner();
|
||||||
|
},
|
||||||
|
(Object error, StackTrace stackTrace) {
|
||||||
|
debugPrint('[ErrorZone] 미처리 에러: $error');
|
||||||
|
ErrorLogger.instance.log(error, stackTrace: stackTrace);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
276
lib/src/core/model/cumulative_statistics.dart
Normal file
276
lib/src/core/model/cumulative_statistics.dart
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import 'package:asciineverdie/src/core/model/session_statistics.dart';
|
||||||
|
|
||||||
|
/// 누적 통계 (Cumulative Statistics)
|
||||||
|
///
|
||||||
|
/// GameStatistics에서 분리된 모든 게임 세션의 누적 통계 모델.
|
||||||
|
class CumulativeStatistics {
|
||||||
|
const CumulativeStatistics({
|
||||||
|
required this.totalPlayTimeMs,
|
||||||
|
required this.totalMonstersKilled,
|
||||||
|
required this.totalGoldEarned,
|
||||||
|
required this.totalGoldSpent,
|
||||||
|
required this.totalSkillsUsed,
|
||||||
|
required this.totalCriticalHits,
|
||||||
|
required this.bestCriticalStreak,
|
||||||
|
required this.totalDamageDealt,
|
||||||
|
required this.totalDamageTaken,
|
||||||
|
required this.totalPotionsUsed,
|
||||||
|
required this.totalItemsSold,
|
||||||
|
required this.totalQuestsCompleted,
|
||||||
|
required this.totalDeaths,
|
||||||
|
required this.totalBossesDefeated,
|
||||||
|
required this.totalLevelUps,
|
||||||
|
required this.highestLevel,
|
||||||
|
required this.highestGoldHeld,
|
||||||
|
required this.gamesCompleted,
|
||||||
|
required this.gamesStarted,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 총 플레이 시간 (밀리초)
|
||||||
|
final int totalPlayTimeMs;
|
||||||
|
|
||||||
|
/// 총 처치한 몬스터 수
|
||||||
|
final int totalMonstersKilled;
|
||||||
|
|
||||||
|
/// 총 획득한 골드
|
||||||
|
final int totalGoldEarned;
|
||||||
|
|
||||||
|
/// 총 소비한 골드
|
||||||
|
final int totalGoldSpent;
|
||||||
|
|
||||||
|
/// 총 스킬 사용 횟수
|
||||||
|
final int totalSkillsUsed;
|
||||||
|
|
||||||
|
/// 총 크리티컬 히트 횟수
|
||||||
|
final int totalCriticalHits;
|
||||||
|
|
||||||
|
/// 최고 연속 크리티컬
|
||||||
|
final int bestCriticalStreak;
|
||||||
|
|
||||||
|
/// 총 입힌 데미지
|
||||||
|
final int totalDamageDealt;
|
||||||
|
|
||||||
|
/// 총 받은 데미지
|
||||||
|
final int totalDamageTaken;
|
||||||
|
|
||||||
|
/// 총 사용한 물약 수
|
||||||
|
final int totalPotionsUsed;
|
||||||
|
|
||||||
|
/// 총 판매한 아이템 수
|
||||||
|
final int totalItemsSold;
|
||||||
|
|
||||||
|
/// 총 완료한 퀘스트 수
|
||||||
|
final int totalQuestsCompleted;
|
||||||
|
|
||||||
|
/// 총 사망 횟수
|
||||||
|
final int totalDeaths;
|
||||||
|
|
||||||
|
/// 총 처치한 보스 수
|
||||||
|
final int totalBossesDefeated;
|
||||||
|
|
||||||
|
/// 총 레벨업 횟수
|
||||||
|
final int totalLevelUps;
|
||||||
|
|
||||||
|
/// 최고 달성 레벨
|
||||||
|
final int highestLevel;
|
||||||
|
|
||||||
|
/// 최대 보유 골드
|
||||||
|
final int highestGoldHeld;
|
||||||
|
|
||||||
|
/// 클리어한 게임 수
|
||||||
|
final int gamesCompleted;
|
||||||
|
|
||||||
|
/// 시작한 게임 수
|
||||||
|
final int gamesStarted;
|
||||||
|
|
||||||
|
/// 총 플레이 시간 Duration
|
||||||
|
Duration get totalPlayTime => Duration(milliseconds: totalPlayTimeMs);
|
||||||
|
|
||||||
|
/// 총 플레이 시간 포맷 (HH:MM:SS)
|
||||||
|
String get formattedTotalPlayTime {
|
||||||
|
final hours = totalPlayTime.inHours;
|
||||||
|
final minutes = totalPlayTime.inMinutes % 60;
|
||||||
|
final seconds = totalPlayTime.inSeconds % 60;
|
||||||
|
return '${hours.toString().padLeft(2, '0')}:'
|
||||||
|
'${minutes.toString().padLeft(2, '0')}:'
|
||||||
|
'${seconds.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 평균 게임당 플레이 시간
|
||||||
|
Duration get averagePlayTimePerGame {
|
||||||
|
if (gamesStarted <= 0) return Duration.zero;
|
||||||
|
return Duration(milliseconds: totalPlayTimeMs ~/ gamesStarted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 게임 완료율
|
||||||
|
double get completionRate {
|
||||||
|
if (gamesStarted <= 0) return 0;
|
||||||
|
return gamesCompleted / gamesStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 빈 누적 통계
|
||||||
|
factory CumulativeStatistics.empty() => const CumulativeStatistics(
|
||||||
|
totalPlayTimeMs: 0,
|
||||||
|
totalMonstersKilled: 0,
|
||||||
|
totalGoldEarned: 0,
|
||||||
|
totalGoldSpent: 0,
|
||||||
|
totalSkillsUsed: 0,
|
||||||
|
totalCriticalHits: 0,
|
||||||
|
bestCriticalStreak: 0,
|
||||||
|
totalDamageDealt: 0,
|
||||||
|
totalDamageTaken: 0,
|
||||||
|
totalPotionsUsed: 0,
|
||||||
|
totalItemsSold: 0,
|
||||||
|
totalQuestsCompleted: 0,
|
||||||
|
totalDeaths: 0,
|
||||||
|
totalBossesDefeated: 0,
|
||||||
|
totalLevelUps: 0,
|
||||||
|
highestLevel: 0,
|
||||||
|
highestGoldHeld: 0,
|
||||||
|
gamesCompleted: 0,
|
||||||
|
gamesStarted: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 세션 통계 병합
|
||||||
|
CumulativeStatistics mergeSession(SessionStatistics session) {
|
||||||
|
return CumulativeStatistics(
|
||||||
|
totalPlayTimeMs: totalPlayTimeMs + session.playTimeMs,
|
||||||
|
totalMonstersKilled: totalMonstersKilled + session.monstersKilled,
|
||||||
|
totalGoldEarned: totalGoldEarned + session.goldEarned,
|
||||||
|
totalGoldSpent: totalGoldSpent + session.goldSpent,
|
||||||
|
totalSkillsUsed: totalSkillsUsed + session.skillsUsed,
|
||||||
|
totalCriticalHits: totalCriticalHits + session.criticalHits,
|
||||||
|
bestCriticalStreak: session.maxCriticalStreak > bestCriticalStreak
|
||||||
|
? session.maxCriticalStreak
|
||||||
|
: bestCriticalStreak,
|
||||||
|
totalDamageDealt: totalDamageDealt + session.totalDamageDealt,
|
||||||
|
totalDamageTaken: totalDamageTaken + session.totalDamageTaken,
|
||||||
|
totalPotionsUsed: totalPotionsUsed + session.potionsUsed,
|
||||||
|
totalItemsSold: totalItemsSold + session.itemsSold,
|
||||||
|
totalQuestsCompleted: totalQuestsCompleted + session.questsCompleted,
|
||||||
|
totalDeaths: totalDeaths + session.deathCount,
|
||||||
|
totalBossesDefeated: totalBossesDefeated + session.bossesDefeated,
|
||||||
|
totalLevelUps: totalLevelUps + session.levelUps,
|
||||||
|
highestLevel: highestLevel,
|
||||||
|
highestGoldHeld: highestGoldHeld,
|
||||||
|
gamesCompleted: gamesCompleted,
|
||||||
|
gamesStarted: gamesStarted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 최고 레벨 업데이트
|
||||||
|
CumulativeStatistics updateHighestLevel(int level) {
|
||||||
|
if (level <= highestLevel) return this;
|
||||||
|
return copyWith(highestLevel: level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 최대 골드 업데이트
|
||||||
|
CumulativeStatistics updateHighestGold(int gold) {
|
||||||
|
if (gold <= highestGoldHeld) return this;
|
||||||
|
return copyWith(highestGoldHeld: gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 새 게임 시작 기록
|
||||||
|
CumulativeStatistics recordGameStart() {
|
||||||
|
return copyWith(gamesStarted: gamesStarted + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 게임 클리어 기록
|
||||||
|
CumulativeStatistics recordGameComplete() {
|
||||||
|
return copyWith(gamesCompleted: gamesCompleted + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
CumulativeStatistics copyWith({
|
||||||
|
int? totalPlayTimeMs,
|
||||||
|
int? totalMonstersKilled,
|
||||||
|
int? totalGoldEarned,
|
||||||
|
int? totalGoldSpent,
|
||||||
|
int? totalSkillsUsed,
|
||||||
|
int? totalCriticalHits,
|
||||||
|
int? bestCriticalStreak,
|
||||||
|
int? totalDamageDealt,
|
||||||
|
int? totalDamageTaken,
|
||||||
|
int? totalPotionsUsed,
|
||||||
|
int? totalItemsSold,
|
||||||
|
int? totalQuestsCompleted,
|
||||||
|
int? totalDeaths,
|
||||||
|
int? totalBossesDefeated,
|
||||||
|
int? totalLevelUps,
|
||||||
|
int? highestLevel,
|
||||||
|
int? highestGoldHeld,
|
||||||
|
int? gamesCompleted,
|
||||||
|
int? gamesStarted,
|
||||||
|
}) {
|
||||||
|
return CumulativeStatistics(
|
||||||
|
totalPlayTimeMs: totalPlayTimeMs ?? this.totalPlayTimeMs,
|
||||||
|
totalMonstersKilled: totalMonstersKilled ?? this.totalMonstersKilled,
|
||||||
|
totalGoldEarned: totalGoldEarned ?? this.totalGoldEarned,
|
||||||
|
totalGoldSpent: totalGoldSpent ?? this.totalGoldSpent,
|
||||||
|
totalSkillsUsed: totalSkillsUsed ?? this.totalSkillsUsed,
|
||||||
|
totalCriticalHits: totalCriticalHits ?? this.totalCriticalHits,
|
||||||
|
bestCriticalStreak: bestCriticalStreak ?? this.bestCriticalStreak,
|
||||||
|
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
|
||||||
|
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
|
||||||
|
totalPotionsUsed: totalPotionsUsed ?? this.totalPotionsUsed,
|
||||||
|
totalItemsSold: totalItemsSold ?? this.totalItemsSold,
|
||||||
|
totalQuestsCompleted: totalQuestsCompleted ?? this.totalQuestsCompleted,
|
||||||
|
totalDeaths: totalDeaths ?? this.totalDeaths,
|
||||||
|
totalBossesDefeated: totalBossesDefeated ?? this.totalBossesDefeated,
|
||||||
|
totalLevelUps: totalLevelUps ?? this.totalLevelUps,
|
||||||
|
highestLevel: highestLevel ?? this.highestLevel,
|
||||||
|
highestGoldHeld: highestGoldHeld ?? this.highestGoldHeld,
|
||||||
|
gamesCompleted: gamesCompleted ?? this.gamesCompleted,
|
||||||
|
gamesStarted: gamesStarted ?? this.gamesStarted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON 직렬화
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'totalPlayTimeMs': totalPlayTimeMs,
|
||||||
|
'totalMonstersKilled': totalMonstersKilled,
|
||||||
|
'totalGoldEarned': totalGoldEarned,
|
||||||
|
'totalGoldSpent': totalGoldSpent,
|
||||||
|
'totalSkillsUsed': totalSkillsUsed,
|
||||||
|
'totalCriticalHits': totalCriticalHits,
|
||||||
|
'bestCriticalStreak': bestCriticalStreak,
|
||||||
|
'totalDamageDealt': totalDamageDealt,
|
||||||
|
'totalDamageTaken': totalDamageTaken,
|
||||||
|
'totalPotionsUsed': totalPotionsUsed,
|
||||||
|
'totalItemsSold': totalItemsSold,
|
||||||
|
'totalQuestsCompleted': totalQuestsCompleted,
|
||||||
|
'totalDeaths': totalDeaths,
|
||||||
|
'totalBossesDefeated': totalBossesDefeated,
|
||||||
|
'totalLevelUps': totalLevelUps,
|
||||||
|
'highestLevel': highestLevel,
|
||||||
|
'highestGoldHeld': highestGoldHeld,
|
||||||
|
'gamesCompleted': gamesCompleted,
|
||||||
|
'gamesStarted': gamesStarted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON 역직렬화
|
||||||
|
factory CumulativeStatistics.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CumulativeStatistics(
|
||||||
|
totalPlayTimeMs: json['totalPlayTimeMs'] as int? ?? 0,
|
||||||
|
totalMonstersKilled: json['totalMonstersKilled'] as int? ?? 0,
|
||||||
|
totalGoldEarned: json['totalGoldEarned'] as int? ?? 0,
|
||||||
|
totalGoldSpent: json['totalGoldSpent'] as int? ?? 0,
|
||||||
|
totalSkillsUsed: json['totalSkillsUsed'] as int? ?? 0,
|
||||||
|
totalCriticalHits: json['totalCriticalHits'] as int? ?? 0,
|
||||||
|
bestCriticalStreak: json['bestCriticalStreak'] as int? ?? 0,
|
||||||
|
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
|
||||||
|
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
|
||||||
|
totalPotionsUsed: json['totalPotionsUsed'] as int? ?? 0,
|
||||||
|
totalItemsSold: json['totalItemsSold'] as int? ?? 0,
|
||||||
|
totalQuestsCompleted: json['totalQuestsCompleted'] as int? ?? 0,
|
||||||
|
totalDeaths: json['totalDeaths'] as int? ?? 0,
|
||||||
|
totalBossesDefeated: json['totalBossesDefeated'] as int? ?? 0,
|
||||||
|
totalLevelUps: json['totalLevelUps'] as int? ?? 0,
|
||||||
|
highestLevel: json['highestLevel'] as int? ?? 0,
|
||||||
|
highestGoldHeld: json['highestGoldHeld'] as int? ?? 0,
|
||||||
|
gamesCompleted: json['gamesCompleted'] as int? ?? 0,
|
||||||
|
gamesStarted: json['gamesStarted'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
lib/src/core/model/death_info.dart
Normal file
96
lib/src/core/model/death_info.dart
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||||
|
|
||||||
|
/// 사망 원인 (Death Cause)
|
||||||
|
enum DeathCause {
|
||||||
|
/// 몬스터에 의한 사망
|
||||||
|
monster,
|
||||||
|
|
||||||
|
/// 자해 스킬에 의한 사망
|
||||||
|
selfDamage,
|
||||||
|
|
||||||
|
/// 환경 피해에 의한 사망
|
||||||
|
environment,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 사망 정보 (Phase 4)
|
||||||
|
///
|
||||||
|
/// 사망 시점의 정보와 상실한 아이템을 기록
|
||||||
|
class DeathInfo {
|
||||||
|
const DeathInfo({
|
||||||
|
required this.cause,
|
||||||
|
required this.killerName,
|
||||||
|
required this.lostEquipmentCount,
|
||||||
|
required this.goldAtDeath,
|
||||||
|
required this.levelAtDeath,
|
||||||
|
required this.timestamp,
|
||||||
|
this.lostItemName,
|
||||||
|
this.lostItemSlot,
|
||||||
|
this.lostItemRarity,
|
||||||
|
this.lostItem,
|
||||||
|
this.lastCombatEvents = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 사망 원인
|
||||||
|
final DeathCause cause;
|
||||||
|
|
||||||
|
/// 사망시킨 몬스터/원인 이름
|
||||||
|
final String killerName;
|
||||||
|
|
||||||
|
/// 상실한 장비 개수 (0 또는 1)
|
||||||
|
final int lostEquipmentCount;
|
||||||
|
|
||||||
|
/// 제물로 바친 아이템 이름 (null이면 없음)
|
||||||
|
final String? lostItemName;
|
||||||
|
|
||||||
|
/// 제물로 바친 아이템 슬롯 (null이면 없음)
|
||||||
|
final EquipmentSlot? lostItemSlot;
|
||||||
|
|
||||||
|
/// 제물로 바친 아이템 희귀도 (null이면 없음)
|
||||||
|
final ItemRarity? lostItemRarity;
|
||||||
|
|
||||||
|
/// 상실한 장비 전체 정보 (광고 부활 시 복구용)
|
||||||
|
final EquipmentItem? lostItem;
|
||||||
|
|
||||||
|
/// 사망 시점 골드
|
||||||
|
final int goldAtDeath;
|
||||||
|
|
||||||
|
/// 사망 시점 레벨
|
||||||
|
final int levelAtDeath;
|
||||||
|
|
||||||
|
/// 사망 시각 (밀리초)
|
||||||
|
final int timestamp;
|
||||||
|
|
||||||
|
/// 사망 직전 전투 이벤트 (최대 10개)
|
||||||
|
final List<CombatEvent> lastCombatEvents;
|
||||||
|
|
||||||
|
DeathInfo copyWith({
|
||||||
|
DeathCause? cause,
|
||||||
|
String? killerName,
|
||||||
|
int? lostEquipmentCount,
|
||||||
|
String? lostItemName,
|
||||||
|
EquipmentSlot? lostItemSlot,
|
||||||
|
ItemRarity? lostItemRarity,
|
||||||
|
EquipmentItem? lostItem,
|
||||||
|
int? goldAtDeath,
|
||||||
|
int? levelAtDeath,
|
||||||
|
int? timestamp,
|
||||||
|
List<CombatEvent>? lastCombatEvents,
|
||||||
|
}) {
|
||||||
|
return DeathInfo(
|
||||||
|
cause: cause ?? this.cause,
|
||||||
|
killerName: killerName ?? this.killerName,
|
||||||
|
lostEquipmentCount: lostEquipmentCount ?? this.lostEquipmentCount,
|
||||||
|
lostItemName: lostItemName ?? this.lostItemName,
|
||||||
|
lostItemSlot: lostItemSlot ?? this.lostItemSlot,
|
||||||
|
lostItemRarity: lostItemRarity ?? this.lostItemRarity,
|
||||||
|
lostItem: lostItem ?? this.lostItem,
|
||||||
|
goldAtDeath: goldAtDeath ?? this.goldAtDeath,
|
||||||
|
levelAtDeath: levelAtDeath ?? this.levelAtDeath,
|
||||||
|
timestamp: timestamp ?? this.timestamp,
|
||||||
|
lastCombatEvents: lastCombatEvents ?? this.lastCombatEvents,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
220
lib/src/core/model/equipment_container.dart
Normal file
220
lib/src/core/model/equipment_container.dart
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||||
|
|
||||||
|
/// 장비 (원본 Main.dfm Equips ListView, 11개 슬롯)
|
||||||
|
///
|
||||||
|
/// Phase 2에서 EquipmentItem 기반으로 확장됨.
|
||||||
|
/// 기존 문자열 API(weapon, shield 등)는 호환성을 위해 유지.
|
||||||
|
class Equipment {
|
||||||
|
Equipment({required this.items, required this.bestIndex})
|
||||||
|
: assert(items.length == slotCount, 'Equipment must have $slotCount items');
|
||||||
|
|
||||||
|
/// 장비 아이템 목록 (11개 슬롯)
|
||||||
|
final List<EquipmentItem> items;
|
||||||
|
|
||||||
|
/// 최고 아이템 슬롯 인덱스 (원본 Equips.Tag, 0-10)
|
||||||
|
final int bestIndex;
|
||||||
|
|
||||||
|
/// 슬롯 개수
|
||||||
|
static const slotCount = 11;
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// 문자열 API (기존 코드 호환성)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
String get weapon => items[0].name; // 0: 무기
|
||||||
|
String get shield => items[1].name; // 1: 방패
|
||||||
|
String get helm => items[2].name; // 2: 투구
|
||||||
|
String get hauberk => items[3].name; // 3: 사슬갑옷
|
||||||
|
String get brassairts => items[4].name; // 4: 상완갑
|
||||||
|
String get vambraces => items[5].name; // 5: 전완갑
|
||||||
|
String get gauntlets => items[6].name; // 6: 건틀릿
|
||||||
|
String get gambeson => items[7].name; // 7: 갬비슨
|
||||||
|
String get cuisses => items[8].name; // 8: 허벅지갑
|
||||||
|
String get greaves => items[9].name; // 9: 정강이갑
|
||||||
|
String get sollerets => items[10].name; // 10: 철제신발
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// EquipmentItem API
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
EquipmentItem get weaponItem => items[0];
|
||||||
|
EquipmentItem get shieldItem => items[1];
|
||||||
|
EquipmentItem get helmItem => items[2];
|
||||||
|
EquipmentItem get hauberkItem => items[3];
|
||||||
|
EquipmentItem get brassairtsItem => items[4];
|
||||||
|
EquipmentItem get vambracesItem => items[5];
|
||||||
|
EquipmentItem get gauntletsItem => items[6];
|
||||||
|
EquipmentItem get gambesonItem => items[7];
|
||||||
|
EquipmentItem get cuissesItem => items[8];
|
||||||
|
EquipmentItem get greavesItem => items[9];
|
||||||
|
EquipmentItem get solleretsItem => items[10];
|
||||||
|
|
||||||
|
/// 모든 장비 스탯 합산
|
||||||
|
ItemStats get totalStats {
|
||||||
|
return items.fold(ItemStats.empty, (sum, item) => sum + item.stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 모든 장비 무게 합산
|
||||||
|
int get totalWeight {
|
||||||
|
return items.fold(0, (sum, item) => sum + item.weight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 장착된 아이템 목록 (빈 슬롯 제외)
|
||||||
|
List<EquipmentItem> get equippedItems {
|
||||||
|
return items.where((item) => item.isNotEmpty).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// 팩토리 메서드
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
factory Equipment.empty() {
|
||||||
|
return Equipment(
|
||||||
|
items: [
|
||||||
|
EquipmentItem.defaultWeapon(), // 0: 무기 (Keyboard)
|
||||||
|
EquipmentItem.empty(EquipmentSlot.shield), // 1: 방패
|
||||||
|
EquipmentItem.empty(EquipmentSlot.helm), // 2: 투구
|
||||||
|
EquipmentItem.empty(EquipmentSlot.hauberk), // 3: 사슬갑옷
|
||||||
|
EquipmentItem.empty(EquipmentSlot.brassairts), // 4: 상완갑
|
||||||
|
EquipmentItem.empty(EquipmentSlot.vambraces), // 5: 전완갑
|
||||||
|
EquipmentItem.empty(EquipmentSlot.gauntlets), // 6: 건틀릿
|
||||||
|
EquipmentItem.empty(EquipmentSlot.gambeson), // 7: 갬비슨
|
||||||
|
EquipmentItem.empty(EquipmentSlot.cuisses), // 8: 허벅지갑
|
||||||
|
EquipmentItem.empty(EquipmentSlot.greaves), // 9: 정강이갑
|
||||||
|
EquipmentItem.empty(EquipmentSlot.sollerets), // 10: 철제신발
|
||||||
|
],
|
||||||
|
bestIndex: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 초보자 장비 세트 (모든 슬롯에 기본 방어구 지급)
|
||||||
|
///
|
||||||
|
/// 원본 PQ에서는 초기 장비가 없지만, 밸런스 개선을 위해
|
||||||
|
/// Act 1 완료 전에도 기본 방어력을 제공.
|
||||||
|
factory Equipment.withStarterGear() {
|
||||||
|
return Equipment(
|
||||||
|
items: [
|
||||||
|
EquipmentItem.defaultWeapon(), // 0: 무기 (Keyboard)
|
||||||
|
_starterArmor('Old Mouse', EquipmentSlot.shield, def: 2),
|
||||||
|
_starterArmor('Cardboard Box', EquipmentSlot.helm, def: 1),
|
||||||
|
_starterArmor('Worn T-Shirt', EquipmentSlot.hauberk, def: 3),
|
||||||
|
_starterArmor('Rubber Band', EquipmentSlot.brassairts, def: 1),
|
||||||
|
_starterArmor('Wristwatch', EquipmentSlot.vambraces, def: 1),
|
||||||
|
_starterArmor('Fingerless Gloves', EquipmentSlot.gauntlets, def: 1),
|
||||||
|
_starterArmor('Hoodie', EquipmentSlot.gambeson, def: 2),
|
||||||
|
_starterArmor('Jeans', EquipmentSlot.cuisses, def: 2),
|
||||||
|
_starterArmor('Knee Pads', EquipmentSlot.greaves, def: 1),
|
||||||
|
_starterArmor('Sneakers', EquipmentSlot.sollerets, def: 1),
|
||||||
|
],
|
||||||
|
bestIndex: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 초보자 방어구 생성 헬퍼
|
||||||
|
static EquipmentItem _starterArmor(
|
||||||
|
String name,
|
||||||
|
EquipmentSlot slot, {
|
||||||
|
required int def,
|
||||||
|
}) {
|
||||||
|
return EquipmentItem(
|
||||||
|
name: name,
|
||||||
|
slot: slot,
|
||||||
|
level: 1,
|
||||||
|
weight: 1,
|
||||||
|
stats: ItemStats(def: def),
|
||||||
|
rarity: ItemRarity.common,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 레거시 문자열 기반 생성자 (세이브 파일 호환용)
|
||||||
|
factory Equipment.fromStrings({
|
||||||
|
required String weapon,
|
||||||
|
required String shield,
|
||||||
|
required String helm,
|
||||||
|
required String hauberk,
|
||||||
|
required String brassairts,
|
||||||
|
required String vambraces,
|
||||||
|
required String gauntlets,
|
||||||
|
required String gambeson,
|
||||||
|
required String cuisses,
|
||||||
|
required String greaves,
|
||||||
|
required String sollerets,
|
||||||
|
required int bestIndex,
|
||||||
|
}) {
|
||||||
|
return Equipment(
|
||||||
|
items: [
|
||||||
|
_itemFromString(weapon, EquipmentSlot.weapon),
|
||||||
|
_itemFromString(shield, EquipmentSlot.shield),
|
||||||
|
_itemFromString(helm, EquipmentSlot.helm),
|
||||||
|
_itemFromString(hauberk, EquipmentSlot.hauberk),
|
||||||
|
_itemFromString(brassairts, EquipmentSlot.brassairts),
|
||||||
|
_itemFromString(vambraces, EquipmentSlot.vambraces),
|
||||||
|
_itemFromString(gauntlets, EquipmentSlot.gauntlets),
|
||||||
|
_itemFromString(gambeson, EquipmentSlot.gambeson),
|
||||||
|
_itemFromString(cuisses, EquipmentSlot.cuisses),
|
||||||
|
_itemFromString(greaves, EquipmentSlot.greaves),
|
||||||
|
_itemFromString(sollerets, EquipmentSlot.sollerets),
|
||||||
|
],
|
||||||
|
bestIndex: bestIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 문자열에서 기본 EquipmentItem 생성 (레거시 호환)
|
||||||
|
static EquipmentItem _itemFromString(String name, EquipmentSlot slot) {
|
||||||
|
if (name.isEmpty) {
|
||||||
|
return EquipmentItem.empty(slot);
|
||||||
|
}
|
||||||
|
// 레거시 아이템: 레벨 1, Common, 기본 스탯
|
||||||
|
return EquipmentItem(
|
||||||
|
name: name,
|
||||||
|
slot: slot,
|
||||||
|
level: 1,
|
||||||
|
weight: 5,
|
||||||
|
stats: ItemStats.empty,
|
||||||
|
rarity: ItemRarity.common,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// 유틸리티 메서드
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/// 인덱스로 슬롯 이름 가져오기 (기존 API 호환)
|
||||||
|
String getByIndex(int index) {
|
||||||
|
if (index < 0 || index >= slotCount) return '';
|
||||||
|
return items[index].name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 인덱스로 EquipmentItem 가져오기
|
||||||
|
EquipmentItem getItemByIndex(int index) {
|
||||||
|
if (index < 0 || index >= slotCount) {
|
||||||
|
return EquipmentItem.empty(EquipmentSlot.weapon);
|
||||||
|
}
|
||||||
|
return items[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 인덱스로 슬롯 값 설정 (문자열, 기존 API 호환)
|
||||||
|
Equipment setByIndex(int index, String value) {
|
||||||
|
if (index < 0 || index >= slotCount) return this;
|
||||||
|
final slot = EquipmentSlot.values[index];
|
||||||
|
final newItem = _itemFromString(value, slot);
|
||||||
|
return setItemByIndex(index, newItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 인덱스로 EquipmentItem 설정
|
||||||
|
Equipment setItemByIndex(int index, EquipmentItem item) {
|
||||||
|
if (index < 0 || index >= slotCount) return this;
|
||||||
|
final newItems = List<EquipmentItem>.from(items);
|
||||||
|
newItems[index] = item;
|
||||||
|
return Equipment(items: newItems, bestIndex: bestIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
Equipment copyWith({List<EquipmentItem>? items, int? bestIndex}) {
|
||||||
|
return Equipment(
|
||||||
|
items: items ?? List<EquipmentItem>.from(this.items),
|
||||||
|
bestIndex: bestIndex ?? this.bestIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,16 +17,23 @@ class EquipmentItem with _$EquipmentItem {
|
|||||||
const factory EquipmentItem({
|
const factory EquipmentItem({
|
||||||
/// 아이템 이름 (예: "Flaming Sword of Doom")
|
/// 아이템 이름 (예: "Flaming Sword of Doom")
|
||||||
required String name,
|
required String name,
|
||||||
|
|
||||||
/// 장착 슬롯
|
/// 장착 슬롯
|
||||||
|
// ignore: invalid_annotation_target
|
||||||
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
|
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
|
||||||
required EquipmentSlot slot,
|
required EquipmentSlot slot,
|
||||||
|
|
||||||
/// 아이템 레벨
|
/// 아이템 레벨
|
||||||
required int level,
|
required int level,
|
||||||
|
|
||||||
/// 무게 (STR 기반 휴대 제한용)
|
/// 무게 (STR 기반 휴대 제한용)
|
||||||
required int weight,
|
required int weight,
|
||||||
|
|
||||||
/// 아이템 스탯 보정치
|
/// 아이템 스탯 보정치
|
||||||
required ItemStats stats,
|
required ItemStats stats,
|
||||||
|
|
||||||
/// 희귀도
|
/// 희귀도
|
||||||
|
// ignore: invalid_annotation_target
|
||||||
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
|
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
|
||||||
required ItemRarity rarity,
|
required ItemRarity rarity,
|
||||||
}) = _EquipmentItem;
|
}) = _EquipmentItem;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ mixin _$EquipmentItem {
|
|||||||
String get name => throw _privateConstructorUsedError;
|
String get name => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
/// 장착 슬롯
|
/// 장착 슬롯
|
||||||
|
// ignore: invalid_annotation_target
|
||||||
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
|
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
|
||||||
EquipmentSlot get slot => throw _privateConstructorUsedError;
|
EquipmentSlot get slot => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ mixin _$EquipmentItem {
|
|||||||
ItemStats get stats => throw _privateConstructorUsedError;
|
ItemStats get stats => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
/// 희귀도
|
/// 희귀도
|
||||||
|
// ignore: invalid_annotation_target
|
||||||
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
|
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
|
||||||
ItemRarity get rarity => throw _privateConstructorUsedError;
|
ItemRarity get rarity => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
@@ -231,6 +233,7 @@ class _$EquipmentItemImpl extends _EquipmentItem {
|
|||||||
final String name;
|
final String name;
|
||||||
|
|
||||||
/// 장착 슬롯
|
/// 장착 슬롯
|
||||||
|
// ignore: invalid_annotation_target
|
||||||
@override
|
@override
|
||||||
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
|
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
|
||||||
final EquipmentSlot slot;
|
final EquipmentSlot slot;
|
||||||
@@ -248,6 +251,7 @@ class _$EquipmentItemImpl extends _EquipmentItem {
|
|||||||
final ItemStats stats;
|
final ItemStats stats;
|
||||||
|
|
||||||
/// 희귀도
|
/// 희귀도
|
||||||
|
// ignore: invalid_annotation_target
|
||||||
@override
|
@override
|
||||||
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
|
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
|
||||||
final ItemRarity rarity;
|
final ItemRarity rarity;
|
||||||
@@ -305,6 +309,7 @@ abstract class _EquipmentItem extends EquipmentItem {
|
|||||||
String get name;
|
String get name;
|
||||||
|
|
||||||
/// 장착 슬롯
|
/// 장착 슬롯
|
||||||
|
// ignore: invalid_annotation_target
|
||||||
@override
|
@override
|
||||||
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
|
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
|
||||||
EquipmentSlot get slot;
|
EquipmentSlot get slot;
|
||||||
@@ -322,6 +327,7 @@ abstract class _EquipmentItem extends EquipmentItem {
|
|||||||
ItemStats get stats;
|
ItemStats get stats;
|
||||||
|
|
||||||
/// 희귀도
|
/// 희귀도
|
||||||
|
// ignore: invalid_annotation_target
|
||||||
@override
|
@override
|
||||||
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
|
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
|
||||||
ItemRarity get rarity;
|
ItemRarity get rarity;
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
import 'dart:collection';
|
/// 게임 상태 모듈 (Game State Module)
|
||||||
|
///
|
||||||
|
/// 하위 파일들을 re-export하여 기존 import 호환성 유지
|
||||||
|
library;
|
||||||
|
|
||||||
import 'package:asciineverdie/src/core/animation/monster_size.dart';
|
export 'death_info.dart';
|
||||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
export 'equipment_container.dart';
|
||||||
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
export 'inventory.dart';
|
||||||
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
export 'progress_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
export 'queue_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
export 'skill_book.dart';
|
||||||
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
export 'skill_system_state.dart';
|
||||||
|
export 'stats.dart';
|
||||||
|
export 'task_info.dart';
|
||||||
|
export 'traits.dart';
|
||||||
|
|
||||||
|
import 'package:asciineverdie/src/core/model/death_info.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/equipment_container.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/inventory.dart';
|
||||||
import 'package:asciineverdie/src/core/model/potion.dart';
|
import 'package:asciineverdie/src/core/model/potion.dart';
|
||||||
import 'package:asciineverdie/src/core/model/skill.dart';
|
import 'package:asciineverdie/src/core/model/progress_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/skill_slots.dart';
|
import 'package:asciineverdie/src/core/model/queue_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/skill_book.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/skill_system_state.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/traits.dart';
|
||||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||||
|
|
||||||
/// Minimal skeletal state to mirror Progress Quest structures.
|
/// 게임 전체 상태 (Game State)
|
||||||
///
|
///
|
||||||
/// Logic will be ported faithfully from the Delphi source; this file only
|
/// 게임 진행에 필요한 모든 데이터를 포함하는 불변(immutable) 상태 객체.
|
||||||
/// defines containers and helpers for deterministic RNG.
|
|
||||||
class GameState {
|
class GameState {
|
||||||
GameState({
|
GameState({
|
||||||
required DeterministicRandom rng,
|
required DeterministicRandom rng,
|
||||||
@@ -118,880 +131,3 @@ class GameState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 사망 정보 (Phase 4)
|
|
||||||
///
|
|
||||||
/// 사망 시점의 정보와 상실한 아이템을 기록
|
|
||||||
class DeathInfo {
|
|
||||||
const DeathInfo({
|
|
||||||
required this.cause,
|
|
||||||
required this.killerName,
|
|
||||||
required this.lostEquipmentCount,
|
|
||||||
required this.goldAtDeath,
|
|
||||||
required this.levelAtDeath,
|
|
||||||
required this.timestamp,
|
|
||||||
this.lostItemName,
|
|
||||||
this.lostItemSlot,
|
|
||||||
this.lostItemRarity,
|
|
||||||
this.lostItem,
|
|
||||||
this.lastCombatEvents = const [],
|
|
||||||
});
|
|
||||||
|
|
||||||
/// 사망 원인
|
|
||||||
final DeathCause cause;
|
|
||||||
|
|
||||||
/// 사망시킨 몬스터/원인 이름
|
|
||||||
final String killerName;
|
|
||||||
|
|
||||||
/// 상실한 장비 개수 (0 또는 1)
|
|
||||||
final int lostEquipmentCount;
|
|
||||||
|
|
||||||
/// 제물로 바친 아이템 이름 (null이면 없음)
|
|
||||||
final String? lostItemName;
|
|
||||||
|
|
||||||
/// 제물로 바친 아이템 슬롯 (null이면 없음)
|
|
||||||
final EquipmentSlot? lostItemSlot;
|
|
||||||
|
|
||||||
/// 제물로 바친 아이템 희귀도 (null이면 없음)
|
|
||||||
final ItemRarity? lostItemRarity;
|
|
||||||
|
|
||||||
/// 상실한 장비 전체 정보 (광고 부활 시 복구용)
|
|
||||||
final EquipmentItem? lostItem;
|
|
||||||
|
|
||||||
/// 사망 시점 골드
|
|
||||||
final int goldAtDeath;
|
|
||||||
|
|
||||||
/// 사망 시점 레벨
|
|
||||||
final int levelAtDeath;
|
|
||||||
|
|
||||||
/// 사망 시각 (밀리초)
|
|
||||||
final int timestamp;
|
|
||||||
|
|
||||||
/// 사망 직전 전투 이벤트 (최대 10개)
|
|
||||||
final List<CombatEvent> lastCombatEvents;
|
|
||||||
|
|
||||||
DeathInfo copyWith({
|
|
||||||
DeathCause? cause,
|
|
||||||
String? killerName,
|
|
||||||
int? lostEquipmentCount,
|
|
||||||
String? lostItemName,
|
|
||||||
EquipmentSlot? lostItemSlot,
|
|
||||||
ItemRarity? lostItemRarity,
|
|
||||||
EquipmentItem? lostItem,
|
|
||||||
int? goldAtDeath,
|
|
||||||
int? levelAtDeath,
|
|
||||||
int? timestamp,
|
|
||||||
List<CombatEvent>? lastCombatEvents,
|
|
||||||
}) {
|
|
||||||
return DeathInfo(
|
|
||||||
cause: cause ?? this.cause,
|
|
||||||
killerName: killerName ?? this.killerName,
|
|
||||||
lostEquipmentCount: lostEquipmentCount ?? this.lostEquipmentCount,
|
|
||||||
lostItemName: lostItemName ?? this.lostItemName,
|
|
||||||
lostItemSlot: lostItemSlot ?? this.lostItemSlot,
|
|
||||||
lostItemRarity: lostItemRarity ?? this.lostItemRarity,
|
|
||||||
lostItem: lostItem ?? this.lostItem,
|
|
||||||
goldAtDeath: goldAtDeath ?? this.goldAtDeath,
|
|
||||||
levelAtDeath: levelAtDeath ?? this.levelAtDeath,
|
|
||||||
timestamp: timestamp ?? this.timestamp,
|
|
||||||
lastCombatEvents: lastCombatEvents ?? this.lastCombatEvents,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 사망 원인
|
|
||||||
enum DeathCause {
|
|
||||||
/// 몬스터에 의한 사망
|
|
||||||
monster,
|
|
||||||
|
|
||||||
/// 자해 스킬에 의한 사망
|
|
||||||
selfDamage,
|
|
||||||
|
|
||||||
/// 환경 피해에 의한 사망
|
|
||||||
environment,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 스킬 시스템 상태 (Phase 3)
|
|
||||||
///
|
|
||||||
/// 스킬 쿨타임, 활성 버프, 게임 경과 시간, 장착 스킬 등을 관리
|
|
||||||
class SkillSystemState {
|
|
||||||
const SkillSystemState({
|
|
||||||
required this.skillStates,
|
|
||||||
required this.activeBuffs,
|
|
||||||
required this.elapsedMs,
|
|
||||||
this.equippedSkills = const SkillSlots(),
|
|
||||||
this.globalCooldownEndMs = 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// 글로벌 쿨타임 (GCD) 상수: 1500ms
|
|
||||||
static const int globalCooldownDuration = 1500;
|
|
||||||
|
|
||||||
/// 스킬별 쿨타임 상태
|
|
||||||
final List<SkillState> skillStates;
|
|
||||||
|
|
||||||
/// 현재 활성화된 버프 목록
|
|
||||||
final List<ActiveBuff> activeBuffs;
|
|
||||||
|
|
||||||
/// 게임 진행 경과 시간 (밀리초, 스킬 쿨타임 계산용)
|
|
||||||
final int elapsedMs;
|
|
||||||
|
|
||||||
/// 장착된 스킬 슬롯 (타입별 제한 있음)
|
|
||||||
final SkillSlots equippedSkills;
|
|
||||||
|
|
||||||
/// 글로벌 쿨타임 종료 시점 (elapsedMs 기준)
|
|
||||||
final int globalCooldownEndMs;
|
|
||||||
|
|
||||||
/// GCD가 활성화 중인지 확인
|
|
||||||
bool get isGlobalCooldownActive => elapsedMs < globalCooldownEndMs;
|
|
||||||
|
|
||||||
/// 남은 GCD 시간 (ms)
|
|
||||||
int get remainingGlobalCooldown =>
|
|
||||||
isGlobalCooldownActive ? globalCooldownEndMs - elapsedMs : 0;
|
|
||||||
|
|
||||||
factory SkillSystemState.empty() => const SkillSystemState(
|
|
||||||
skillStates: [],
|
|
||||||
activeBuffs: [],
|
|
||||||
elapsedMs: 0,
|
|
||||||
equippedSkills: SkillSlots(),
|
|
||||||
globalCooldownEndMs: 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// 특정 스킬 상태 가져오기
|
|
||||||
SkillState? getSkillState(String skillId) {
|
|
||||||
for (final state in skillStates) {
|
|
||||||
if (state.skillId == skillId) return state;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 버프 효과 합산 (동일 버프는 중복 적용 안 됨)
|
|
||||||
({double atkMod, double defMod, double criMod, double evasionMod})
|
|
||||||
get totalBuffModifiers {
|
|
||||||
double atkMod = 0;
|
|
||||||
double defMod = 0;
|
|
||||||
double criMod = 0;
|
|
||||||
double evasionMod = 0;
|
|
||||||
|
|
||||||
final seenBuffIds = <String>{};
|
|
||||||
for (final buff in activeBuffs) {
|
|
||||||
if (seenBuffIds.contains(buff.effect.id)) continue;
|
|
||||||
seenBuffIds.add(buff.effect.id);
|
|
||||||
|
|
||||||
if (!buff.isExpired(elapsedMs)) {
|
|
||||||
atkMod += buff.effect.atkModifier;
|
|
||||||
defMod += buff.effect.defModifier;
|
|
||||||
criMod += buff.effect.criRateModifier;
|
|
||||||
evasionMod += buff.effect.evasionModifier;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
atkMod: atkMod,
|
|
||||||
defMod: defMod,
|
|
||||||
criMod: criMod,
|
|
||||||
evasionMod: evasionMod,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
SkillSystemState copyWith({
|
|
||||||
List<SkillState>? skillStates,
|
|
||||||
List<ActiveBuff>? activeBuffs,
|
|
||||||
int? elapsedMs,
|
|
||||||
SkillSlots? equippedSkills,
|
|
||||||
int? globalCooldownEndMs,
|
|
||||||
}) {
|
|
||||||
return SkillSystemState(
|
|
||||||
skillStates: skillStates ?? this.skillStates,
|
|
||||||
activeBuffs: activeBuffs ?? this.activeBuffs,
|
|
||||||
elapsedMs: elapsedMs ?? this.elapsedMs,
|
|
||||||
equippedSkills: equippedSkills ?? this.equippedSkills,
|
|
||||||
globalCooldownEndMs: globalCooldownEndMs ?? this.globalCooldownEndMs,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GCD 시작 (스킬 사용 후 호출)
|
|
||||||
SkillSystemState startGlobalCooldown() {
|
|
||||||
return copyWith(globalCooldownEndMs: elapsedMs + globalCooldownDuration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 태스크 타입 (원본 fTask.Caption 값들에 대응)
|
|
||||||
enum TaskType {
|
|
||||||
neutral, // heading 등 일반 이동
|
|
||||||
kill, // 몬스터 처치
|
|
||||||
load, // 로딩/초기화
|
|
||||||
plot, // 플롯 진행
|
|
||||||
market, // 시장으로 이동 중
|
|
||||||
sell, // 아이템 판매 중
|
|
||||||
buying, // 장비 구매 중
|
|
||||||
}
|
|
||||||
|
|
||||||
class TaskInfo {
|
|
||||||
const TaskInfo({
|
|
||||||
required this.caption,
|
|
||||||
required this.type,
|
|
||||||
this.monsterBaseName,
|
|
||||||
this.monsterPart,
|
|
||||||
this.monsterLevel,
|
|
||||||
this.monsterGrade,
|
|
||||||
this.monsterSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String caption;
|
|
||||||
final TaskType type;
|
|
||||||
|
|
||||||
/// 킬 태스크의 몬스터 기본 이름 (형용사 제외, 예: "Goblin")
|
|
||||||
final String? monsterBaseName;
|
|
||||||
|
|
||||||
/// 킬 태스크의 전리품 부위 (예: "claw", "tail", "*"는 WinItem)
|
|
||||||
final String? monsterPart;
|
|
||||||
|
|
||||||
/// 킬 태스크의 몬스터 레벨 (전투 스탯 계산용)
|
|
||||||
final int? monsterLevel;
|
|
||||||
|
|
||||||
/// 킬 태스크의 몬스터 등급 (Normal/Elite/Boss)
|
|
||||||
final MonsterGrade? monsterGrade;
|
|
||||||
|
|
||||||
/// 킬 태스크의 몬스터 사이즈 (애니메이션 크기 결정용, Act 기반)
|
|
||||||
final MonsterSize? monsterSize;
|
|
||||||
|
|
||||||
factory TaskInfo.empty() =>
|
|
||||||
const TaskInfo(caption: '', type: TaskType.neutral);
|
|
||||||
|
|
||||||
TaskInfo copyWith({
|
|
||||||
String? caption,
|
|
||||||
TaskType? type,
|
|
||||||
String? monsterBaseName,
|
|
||||||
String? monsterPart,
|
|
||||||
int? monsterLevel,
|
|
||||||
MonsterGrade? monsterGrade,
|
|
||||||
MonsterSize? monsterSize,
|
|
||||||
}) {
|
|
||||||
return TaskInfo(
|
|
||||||
caption: caption ?? this.caption,
|
|
||||||
type: type ?? this.type,
|
|
||||||
monsterBaseName: monsterBaseName ?? this.monsterBaseName,
|
|
||||||
monsterPart: monsterPart ?? this.monsterPart,
|
|
||||||
monsterLevel: monsterLevel ?? this.monsterLevel,
|
|
||||||
monsterGrade: monsterGrade ?? this.monsterGrade,
|
|
||||||
monsterSize: monsterSize ?? this.monsterSize,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Traits {
|
|
||||||
const Traits({
|
|
||||||
required this.name,
|
|
||||||
required this.race,
|
|
||||||
required this.klass,
|
|
||||||
required this.level,
|
|
||||||
required this.motto,
|
|
||||||
required this.guild,
|
|
||||||
this.raceId = '',
|
|
||||||
this.classId = '',
|
|
||||||
});
|
|
||||||
|
|
||||||
final String name;
|
|
||||||
|
|
||||||
/// 종족 표시 이름 (예: "Kernel Giant")
|
|
||||||
final String race;
|
|
||||||
|
|
||||||
/// 클래스 표시 이름 (예: "Bug Hunter")
|
|
||||||
final String klass;
|
|
||||||
|
|
||||||
final int level;
|
|
||||||
final String motto;
|
|
||||||
final String guild;
|
|
||||||
|
|
||||||
/// 종족 ID (Phase 5, 예: "kernel_giant")
|
|
||||||
final String raceId;
|
|
||||||
|
|
||||||
/// 클래스 ID (Phase 5, 예: "bug_hunter")
|
|
||||||
final String classId;
|
|
||||||
|
|
||||||
factory Traits.empty() => const Traits(
|
|
||||||
name: '',
|
|
||||||
race: '',
|
|
||||||
klass: '',
|
|
||||||
level: 1,
|
|
||||||
motto: '',
|
|
||||||
guild: '',
|
|
||||||
raceId: '',
|
|
||||||
classId: '',
|
|
||||||
);
|
|
||||||
|
|
||||||
Traits copyWith({
|
|
||||||
String? name,
|
|
||||||
String? race,
|
|
||||||
String? klass,
|
|
||||||
int? level,
|
|
||||||
String? motto,
|
|
||||||
String? guild,
|
|
||||||
String? raceId,
|
|
||||||
String? classId,
|
|
||||||
}) {
|
|
||||||
return Traits(
|
|
||||||
name: name ?? this.name,
|
|
||||||
race: race ?? this.race,
|
|
||||||
klass: klass ?? this.klass,
|
|
||||||
level: level ?? this.level,
|
|
||||||
motto: motto ?? this.motto,
|
|
||||||
guild: guild ?? this.guild,
|
|
||||||
raceId: raceId ?? this.raceId,
|
|
||||||
classId: classId ?? this.classId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Stats {
|
|
||||||
const Stats({
|
|
||||||
required this.str,
|
|
||||||
required this.con,
|
|
||||||
required this.dex,
|
|
||||||
required this.intelligence,
|
|
||||||
required this.wis,
|
|
||||||
required this.cha,
|
|
||||||
required this.hpMax,
|
|
||||||
required this.mpMax,
|
|
||||||
this.hpCurrent,
|
|
||||||
this.mpCurrent,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int str;
|
|
||||||
final int con;
|
|
||||||
final int dex;
|
|
||||||
final int intelligence;
|
|
||||||
final int wis;
|
|
||||||
final int cha;
|
|
||||||
final int hpMax;
|
|
||||||
final int mpMax;
|
|
||||||
|
|
||||||
/// 현재 HP (null이면 hpMax와 동일)
|
|
||||||
final int? hpCurrent;
|
|
||||||
|
|
||||||
/// 현재 MP (null이면 mpMax와 동일)
|
|
||||||
final int? mpCurrent;
|
|
||||||
|
|
||||||
/// 실제 현재 HP 값
|
|
||||||
int get hp => hpCurrent ?? hpMax;
|
|
||||||
|
|
||||||
/// 실제 현재 MP 값
|
|
||||||
int get mp => mpCurrent ?? mpMax;
|
|
||||||
|
|
||||||
factory Stats.empty() => const Stats(
|
|
||||||
str: 0,
|
|
||||||
con: 0,
|
|
||||||
dex: 0,
|
|
||||||
intelligence: 0,
|
|
||||||
wis: 0,
|
|
||||||
cha: 0,
|
|
||||||
hpMax: 0,
|
|
||||||
mpMax: 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
Stats copyWith({
|
|
||||||
int? str,
|
|
||||||
int? con,
|
|
||||||
int? dex,
|
|
||||||
int? intelligence,
|
|
||||||
int? wis,
|
|
||||||
int? cha,
|
|
||||||
int? hpMax,
|
|
||||||
int? mpMax,
|
|
||||||
int? hpCurrent,
|
|
||||||
int? mpCurrent,
|
|
||||||
}) {
|
|
||||||
return Stats(
|
|
||||||
str: str ?? this.str,
|
|
||||||
con: con ?? this.con,
|
|
||||||
dex: dex ?? this.dex,
|
|
||||||
intelligence: intelligence ?? this.intelligence,
|
|
||||||
wis: wis ?? this.wis,
|
|
||||||
cha: cha ?? this.cha,
|
|
||||||
hpMax: hpMax ?? this.hpMax,
|
|
||||||
mpMax: mpMax ?? this.mpMax,
|
|
||||||
hpCurrent: hpCurrent ?? this.hpCurrent,
|
|
||||||
mpCurrent: mpCurrent ?? this.mpCurrent,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class InventoryEntry {
|
|
||||||
const InventoryEntry({required this.name, required this.count});
|
|
||||||
|
|
||||||
final String name;
|
|
||||||
final int count;
|
|
||||||
|
|
||||||
InventoryEntry copyWith({String? name, int? count}) {
|
|
||||||
return InventoryEntry(name: name ?? this.name, count: count ?? this.count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Inventory {
|
|
||||||
const Inventory({required this.gold, required this.items});
|
|
||||||
|
|
||||||
final int gold;
|
|
||||||
final List<InventoryEntry> items;
|
|
||||||
|
|
||||||
/// 초기 골드 1000 지급 (캐릭터 생성 시)
|
|
||||||
factory Inventory.empty() => const Inventory(gold: 1000, items: []);
|
|
||||||
|
|
||||||
Inventory copyWith({int? gold, List<InventoryEntry>? items}) {
|
|
||||||
return Inventory(gold: gold ?? this.gold, items: items ?? this.items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 장비 (원본 Main.dfm Equips ListView, 11개 슬롯)
|
|
||||||
///
|
|
||||||
/// Phase 2에서 EquipmentItem 기반으로 확장됨.
|
|
||||||
/// 기존 문자열 API(weapon, shield 등)는 호환성을 위해 유지.
|
|
||||||
class Equipment {
|
|
||||||
Equipment({required this.items, required this.bestIndex})
|
|
||||||
: assert(items.length == slotCount, 'Equipment must have $slotCount items');
|
|
||||||
|
|
||||||
/// 장비 아이템 목록 (11개 슬롯)
|
|
||||||
final List<EquipmentItem> items;
|
|
||||||
|
|
||||||
/// 최고 아이템 슬롯 인덱스 (원본 Equips.Tag, 0-10)
|
|
||||||
final int bestIndex;
|
|
||||||
|
|
||||||
/// 슬롯 개수
|
|
||||||
static const slotCount = 11;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 문자열 API (기존 코드 호환성)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
String get weapon => items[0].name; // 0: 무기
|
|
||||||
String get shield => items[1].name; // 1: 방패
|
|
||||||
String get helm => items[2].name; // 2: 투구
|
|
||||||
String get hauberk => items[3].name; // 3: 사슬갑옷
|
|
||||||
String get brassairts => items[4].name; // 4: 상완갑
|
|
||||||
String get vambraces => items[5].name; // 5: 전완갑
|
|
||||||
String get gauntlets => items[6].name; // 6: 건틀릿
|
|
||||||
String get gambeson => items[7].name; // 7: 갬비슨
|
|
||||||
String get cuisses => items[8].name; // 8: 허벅지갑
|
|
||||||
String get greaves => items[9].name; // 9: 정강이갑
|
|
||||||
String get sollerets => items[10].name; // 10: 철제신발
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// EquipmentItem API
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
EquipmentItem get weaponItem => items[0];
|
|
||||||
EquipmentItem get shieldItem => items[1];
|
|
||||||
EquipmentItem get helmItem => items[2];
|
|
||||||
EquipmentItem get hauberkItem => items[3];
|
|
||||||
EquipmentItem get brassairtsItem => items[4];
|
|
||||||
EquipmentItem get vambracesItem => items[5];
|
|
||||||
EquipmentItem get gauntletsItem => items[6];
|
|
||||||
EquipmentItem get gambesonItem => items[7];
|
|
||||||
EquipmentItem get cuissesItem => items[8];
|
|
||||||
EquipmentItem get greavesItem => items[9];
|
|
||||||
EquipmentItem get solleretsItem => items[10];
|
|
||||||
|
|
||||||
/// 모든 장비 스탯 합산
|
|
||||||
ItemStats get totalStats {
|
|
||||||
return items.fold(ItemStats.empty, (sum, item) => sum + item.stats);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 모든 장비 무게 합산
|
|
||||||
int get totalWeight {
|
|
||||||
return items.fold(0, (sum, item) => sum + item.weight);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 장착된 아이템 목록 (빈 슬롯 제외)
|
|
||||||
List<EquipmentItem> get equippedItems {
|
|
||||||
return items.where((item) => item.isNotEmpty).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 팩토리 메서드
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
factory Equipment.empty() {
|
|
||||||
return Equipment(
|
|
||||||
items: [
|
|
||||||
EquipmentItem.defaultWeapon(), // 0: 무기 (Keyboard)
|
|
||||||
EquipmentItem.empty(EquipmentSlot.shield), // 1: 방패
|
|
||||||
EquipmentItem.empty(EquipmentSlot.helm), // 2: 투구
|
|
||||||
EquipmentItem.empty(EquipmentSlot.hauberk), // 3: 사슬갑옷
|
|
||||||
EquipmentItem.empty(EquipmentSlot.brassairts), // 4: 상완갑
|
|
||||||
EquipmentItem.empty(EquipmentSlot.vambraces), // 5: 전완갑
|
|
||||||
EquipmentItem.empty(EquipmentSlot.gauntlets), // 6: 건틀릿
|
|
||||||
EquipmentItem.empty(EquipmentSlot.gambeson), // 7: 갬비슨
|
|
||||||
EquipmentItem.empty(EquipmentSlot.cuisses), // 8: 허벅지갑
|
|
||||||
EquipmentItem.empty(EquipmentSlot.greaves), // 9: 정강이갑
|
|
||||||
EquipmentItem.empty(EquipmentSlot.sollerets), // 10: 철제신발
|
|
||||||
],
|
|
||||||
bestIndex: 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 초보자 장비 세트 (모든 슬롯에 기본 방어구 지급)
|
|
||||||
///
|
|
||||||
/// 원본 PQ에서는 초기 장비가 없지만, 밸런스 개선을 위해
|
|
||||||
/// Act 1 완료 전에도 기본 방어력을 제공.
|
|
||||||
factory Equipment.withStarterGear() {
|
|
||||||
return Equipment(
|
|
||||||
items: [
|
|
||||||
EquipmentItem.defaultWeapon(), // 0: 무기 (Keyboard)
|
|
||||||
_starterArmor('Old Mouse', EquipmentSlot.shield, def: 2),
|
|
||||||
_starterArmor('Cardboard Box', EquipmentSlot.helm, def: 1),
|
|
||||||
_starterArmor('Worn T-Shirt', EquipmentSlot.hauberk, def: 3),
|
|
||||||
_starterArmor('Rubber Band', EquipmentSlot.brassairts, def: 1),
|
|
||||||
_starterArmor('Wristwatch', EquipmentSlot.vambraces, def: 1),
|
|
||||||
_starterArmor('Fingerless Gloves', EquipmentSlot.gauntlets, def: 1),
|
|
||||||
_starterArmor('Hoodie', EquipmentSlot.gambeson, def: 2),
|
|
||||||
_starterArmor('Jeans', EquipmentSlot.cuisses, def: 2),
|
|
||||||
_starterArmor('Knee Pads', EquipmentSlot.greaves, def: 1),
|
|
||||||
_starterArmor('Sneakers', EquipmentSlot.sollerets, def: 1),
|
|
||||||
],
|
|
||||||
bestIndex: 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 초보자 방어구 생성 헬퍼
|
|
||||||
static EquipmentItem _starterArmor(
|
|
||||||
String name,
|
|
||||||
EquipmentSlot slot, {
|
|
||||||
required int def,
|
|
||||||
}) {
|
|
||||||
return EquipmentItem(
|
|
||||||
name: name,
|
|
||||||
slot: slot,
|
|
||||||
level: 1,
|
|
||||||
weight: 1,
|
|
||||||
stats: ItemStats(def: def),
|
|
||||||
rarity: ItemRarity.common,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 레거시 문자열 기반 생성자 (세이브 파일 호환용)
|
|
||||||
factory Equipment.fromStrings({
|
|
||||||
required String weapon,
|
|
||||||
required String shield,
|
|
||||||
required String helm,
|
|
||||||
required String hauberk,
|
|
||||||
required String brassairts,
|
|
||||||
required String vambraces,
|
|
||||||
required String gauntlets,
|
|
||||||
required String gambeson,
|
|
||||||
required String cuisses,
|
|
||||||
required String greaves,
|
|
||||||
required String sollerets,
|
|
||||||
required int bestIndex,
|
|
||||||
}) {
|
|
||||||
return Equipment(
|
|
||||||
items: [
|
|
||||||
_itemFromString(weapon, EquipmentSlot.weapon),
|
|
||||||
_itemFromString(shield, EquipmentSlot.shield),
|
|
||||||
_itemFromString(helm, EquipmentSlot.helm),
|
|
||||||
_itemFromString(hauberk, EquipmentSlot.hauberk),
|
|
||||||
_itemFromString(brassairts, EquipmentSlot.brassairts),
|
|
||||||
_itemFromString(vambraces, EquipmentSlot.vambraces),
|
|
||||||
_itemFromString(gauntlets, EquipmentSlot.gauntlets),
|
|
||||||
_itemFromString(gambeson, EquipmentSlot.gambeson),
|
|
||||||
_itemFromString(cuisses, EquipmentSlot.cuisses),
|
|
||||||
_itemFromString(greaves, EquipmentSlot.greaves),
|
|
||||||
_itemFromString(sollerets, EquipmentSlot.sollerets),
|
|
||||||
],
|
|
||||||
bestIndex: bestIndex,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 문자열에서 기본 EquipmentItem 생성 (레거시 호환)
|
|
||||||
static EquipmentItem _itemFromString(String name, EquipmentSlot slot) {
|
|
||||||
if (name.isEmpty) {
|
|
||||||
return EquipmentItem.empty(slot);
|
|
||||||
}
|
|
||||||
// 레거시 아이템: 레벨 1, Common, 기본 스탯
|
|
||||||
return EquipmentItem(
|
|
||||||
name: name,
|
|
||||||
slot: slot,
|
|
||||||
level: 1,
|
|
||||||
weight: 5,
|
|
||||||
stats: ItemStats.empty,
|
|
||||||
rarity: ItemRarity.common,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 유틸리티 메서드
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 인덱스로 슬롯 이름 가져오기 (기존 API 호환)
|
|
||||||
String getByIndex(int index) {
|
|
||||||
if (index < 0 || index >= slotCount) return '';
|
|
||||||
return items[index].name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 인덱스로 EquipmentItem 가져오기
|
|
||||||
EquipmentItem getItemByIndex(int index) {
|
|
||||||
if (index < 0 || index >= slotCount) {
|
|
||||||
return EquipmentItem.empty(EquipmentSlot.weapon);
|
|
||||||
}
|
|
||||||
return items[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 인덱스로 슬롯 값 설정 (문자열, 기존 API 호환)
|
|
||||||
Equipment setByIndex(int index, String value) {
|
|
||||||
if (index < 0 || index >= slotCount) return this;
|
|
||||||
final slot = EquipmentSlot.values[index];
|
|
||||||
final newItem = _itemFromString(value, slot);
|
|
||||||
return setItemByIndex(index, newItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 인덱스로 EquipmentItem 설정
|
|
||||||
Equipment setItemByIndex(int index, EquipmentItem item) {
|
|
||||||
if (index < 0 || index >= slotCount) return this;
|
|
||||||
final newItems = List<EquipmentItem>.from(items);
|
|
||||||
newItems[index] = item;
|
|
||||||
return Equipment(items: newItems, bestIndex: bestIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
Equipment copyWith({List<EquipmentItem>? items, int? bestIndex}) {
|
|
||||||
return Equipment(
|
|
||||||
items: items ?? List<EquipmentItem>.from(this.items),
|
|
||||||
bestIndex: bestIndex ?? this.bestIndex,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SkillEntry {
|
|
||||||
const SkillEntry({required this.name, required this.rank});
|
|
||||||
|
|
||||||
final String name;
|
|
||||||
final String rank; // e.g., Roman numerals
|
|
||||||
|
|
||||||
SkillEntry copyWith({String? name, String? rank}) {
|
|
||||||
return SkillEntry(name: name ?? this.name, rank: rank ?? this.rank);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SkillBook {
|
|
||||||
const SkillBook({required this.skills});
|
|
||||||
|
|
||||||
final List<SkillEntry> skills;
|
|
||||||
|
|
||||||
factory SkillBook.empty() => const SkillBook(skills: []);
|
|
||||||
|
|
||||||
SkillBook copyWith({List<SkillEntry>? skills}) {
|
|
||||||
return SkillBook(skills: skills ?? this.skills);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProgressBarState {
|
|
||||||
const ProgressBarState({required this.position, required this.max});
|
|
||||||
|
|
||||||
final int position;
|
|
||||||
final int max;
|
|
||||||
|
|
||||||
factory ProgressBarState.empty() =>
|
|
||||||
const ProgressBarState(position: 0, max: 1);
|
|
||||||
|
|
||||||
ProgressBarState copyWith({int? position, int? max}) {
|
|
||||||
return ProgressBarState(
|
|
||||||
position: position ?? this.position,
|
|
||||||
max: max ?? this.max,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 히스토리 엔트리 (Plot/Quest 진행 기록)
|
|
||||||
class HistoryEntry {
|
|
||||||
const HistoryEntry({required this.caption, required this.isComplete});
|
|
||||||
|
|
||||||
/// 표시 텍스트 (예: "Prologue", "Act I", "Exterminate the Goblins")
|
|
||||||
final String caption;
|
|
||||||
|
|
||||||
/// 완료 여부 (원본 StateIndex: 0=진행중, 1=완료)
|
|
||||||
final bool isComplete;
|
|
||||||
|
|
||||||
HistoryEntry copyWith({String? caption, bool? isComplete}) {
|
|
||||||
return HistoryEntry(
|
|
||||||
caption: caption ?? this.caption,
|
|
||||||
isComplete: isComplete ?? this.isComplete,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 현재 퀘스트 몬스터 정보 (원본 fQuest)
|
|
||||||
class QuestMonsterInfo {
|
|
||||||
const QuestMonsterInfo({
|
|
||||||
required this.monsterData,
|
|
||||||
required this.monsterIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// 몬스터 데이터 문자열 (예: "Goblin|3|ear")
|
|
||||||
final String monsterData;
|
|
||||||
|
|
||||||
/// 몬스터 인덱스 (Config.monsters에서의 인덱스)
|
|
||||||
final int monsterIndex;
|
|
||||||
|
|
||||||
static const empty = QuestMonsterInfo(monsterData: '', monsterIndex: -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 최종 보스 상태 (Final Boss State)
|
|
||||||
enum FinalBossState {
|
|
||||||
/// 최종 보스 등장 전
|
|
||||||
notSpawned,
|
|
||||||
|
|
||||||
/// 최종 보스 전투 중
|
|
||||||
fighting,
|
|
||||||
|
|
||||||
/// 최종 보스 처치 완료
|
|
||||||
defeated,
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProgressState {
|
|
||||||
const ProgressState({
|
|
||||||
required this.task,
|
|
||||||
required this.quest,
|
|
||||||
required this.plot,
|
|
||||||
required this.exp,
|
|
||||||
required this.encumbrance,
|
|
||||||
required this.currentTask,
|
|
||||||
required this.plotStageCount,
|
|
||||||
required this.questCount,
|
|
||||||
this.plotHistory = const [],
|
|
||||||
this.questHistory = const [],
|
|
||||||
this.currentQuestMonster,
|
|
||||||
this.currentCombat,
|
|
||||||
this.monstersKilled = 0,
|
|
||||||
this.deathCount = 0,
|
|
||||||
this.finalBossState = FinalBossState.notSpawned,
|
|
||||||
this.pendingActCompletion = false,
|
|
||||||
this.bossLevelingEndTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
final ProgressBarState task;
|
|
||||||
final ProgressBarState quest;
|
|
||||||
final ProgressBarState plot;
|
|
||||||
final ProgressBarState exp;
|
|
||||||
final ProgressBarState encumbrance;
|
|
||||||
final TaskInfo currentTask;
|
|
||||||
final int plotStageCount;
|
|
||||||
final int questCount;
|
|
||||||
|
|
||||||
/// 플롯 히스토리 (Prologue, Act I, Act II, ...)
|
|
||||||
final List<HistoryEntry> plotHistory;
|
|
||||||
|
|
||||||
/// 퀘스트 히스토리 (완료된/진행중인 퀘스트 목록)
|
|
||||||
final List<HistoryEntry> questHistory;
|
|
||||||
|
|
||||||
/// 현재 퀘스트 몬스터 정보 (Exterminate 타입용)
|
|
||||||
final QuestMonsterInfo? currentQuestMonster;
|
|
||||||
|
|
||||||
/// 현재 전투 상태 (킬 태스크 진행 중)
|
|
||||||
final CombatState? currentCombat;
|
|
||||||
|
|
||||||
/// 처치한 몬스터 수
|
|
||||||
final int monstersKilled;
|
|
||||||
|
|
||||||
/// 사망 횟수
|
|
||||||
final int deathCount;
|
|
||||||
|
|
||||||
/// 최종 보스 상태 (Act V)
|
|
||||||
final FinalBossState finalBossState;
|
|
||||||
|
|
||||||
/// Act Boss 처치 대기 중 여부 (처치 후 시네마틱 재생 트리거)
|
|
||||||
final bool pendingActCompletion;
|
|
||||||
|
|
||||||
/// 보스 사망 후 레벨링 모드 종료 시간 (milliseconds since epoch)
|
|
||||||
/// null이면 레벨링 모드 아님, 값이 있으면 해당 시간까지 레벨링
|
|
||||||
final int? bossLevelingEndTime;
|
|
||||||
|
|
||||||
factory ProgressState.empty() => ProgressState(
|
|
||||||
task: ProgressBarState.empty(),
|
|
||||||
quest: ProgressBarState.empty(),
|
|
||||||
plot: ProgressBarState.empty(),
|
|
||||||
exp: ProgressBarState.empty(),
|
|
||||||
encumbrance: ProgressBarState.empty(),
|
|
||||||
currentTask: TaskInfo.empty(),
|
|
||||||
plotStageCount: 1, // Prologue
|
|
||||||
questCount: 0,
|
|
||||||
plotHistory: const [HistoryEntry(caption: 'Prologue', isComplete: false)],
|
|
||||||
questHistory: const [],
|
|
||||||
currentQuestMonster: null,
|
|
||||||
currentCombat: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
ProgressState copyWith({
|
|
||||||
ProgressBarState? task,
|
|
||||||
ProgressBarState? quest,
|
|
||||||
ProgressBarState? plot,
|
|
||||||
ProgressBarState? exp,
|
|
||||||
ProgressBarState? encumbrance,
|
|
||||||
TaskInfo? currentTask,
|
|
||||||
int? plotStageCount,
|
|
||||||
int? questCount,
|
|
||||||
List<HistoryEntry>? plotHistory,
|
|
||||||
List<HistoryEntry>? questHistory,
|
|
||||||
QuestMonsterInfo? currentQuestMonster,
|
|
||||||
CombatState? currentCombat,
|
|
||||||
int? monstersKilled,
|
|
||||||
int? deathCount,
|
|
||||||
FinalBossState? finalBossState,
|
|
||||||
bool? pendingActCompletion,
|
|
||||||
int? bossLevelingEndTime,
|
|
||||||
bool clearBossLevelingEndTime = false,
|
|
||||||
}) {
|
|
||||||
return ProgressState(
|
|
||||||
task: task ?? this.task,
|
|
||||||
quest: quest ?? this.quest,
|
|
||||||
plot: plot ?? this.plot,
|
|
||||||
exp: exp ?? this.exp,
|
|
||||||
encumbrance: encumbrance ?? this.encumbrance,
|
|
||||||
currentTask: currentTask ?? this.currentTask,
|
|
||||||
plotStageCount: plotStageCount ?? this.plotStageCount,
|
|
||||||
questCount: questCount ?? this.questCount,
|
|
||||||
plotHistory: plotHistory ?? this.plotHistory,
|
|
||||||
questHistory: questHistory ?? this.questHistory,
|
|
||||||
currentQuestMonster: currentQuestMonster ?? this.currentQuestMonster,
|
|
||||||
currentCombat: currentCombat ?? this.currentCombat,
|
|
||||||
monstersKilled: monstersKilled ?? this.monstersKilled,
|
|
||||||
deathCount: deathCount ?? this.deathCount,
|
|
||||||
finalBossState: finalBossState ?? this.finalBossState,
|
|
||||||
pendingActCompletion: pendingActCompletion ?? this.pendingActCompletion,
|
|
||||||
bossLevelingEndTime: clearBossLevelingEndTime
|
|
||||||
? null
|
|
||||||
: (bossLevelingEndTime ?? this.bossLevelingEndTime),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 현재 레벨링 모드인지 확인
|
|
||||||
bool get isInBossLevelingMode {
|
|
||||||
if (bossLevelingEndTime == null) return false;
|
|
||||||
return DateTime.now().millisecondsSinceEpoch < bossLevelingEndTime!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class QueueEntry {
|
|
||||||
const QueueEntry({
|
|
||||||
required this.kind,
|
|
||||||
required this.durationMillis,
|
|
||||||
required this.caption,
|
|
||||||
this.taskType = TaskType.neutral,
|
|
||||||
});
|
|
||||||
|
|
||||||
final QueueKind kind;
|
|
||||||
final int durationMillis;
|
|
||||||
final String caption;
|
|
||||||
final TaskType taskType;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum QueueKind { task, plot }
|
|
||||||
|
|
||||||
class QueueState {
|
|
||||||
QueueState({Iterable<QueueEntry>? entries})
|
|
||||||
: entries = Queue<QueueEntry>.from(entries ?? const []);
|
|
||||||
|
|
||||||
final Queue<QueueEntry> entries;
|
|
||||||
|
|
||||||
factory QueueState.empty() => QueueState(entries: const []);
|
|
||||||
|
|
||||||
QueueState copyWith({Iterable<QueueEntry>? entries}) {
|
|
||||||
return QueueState(entries: Queue<QueueEntry>.from(entries ?? this.entries));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
|
import 'package:asciineverdie/src/core/model/cumulative_statistics.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/session_statistics.dart';
|
||||||
|
|
||||||
|
// 하위 호환성(backward compatibility)을 위한 re-export
|
||||||
|
export 'package:asciineverdie/src/core/model/cumulative_statistics.dart';
|
||||||
|
export 'package:asciineverdie/src/core/model/session_statistics.dart';
|
||||||
|
|
||||||
/// 게임 통계 (Game Statistics)
|
/// 게임 통계 (Game Statistics)
|
||||||
///
|
///
|
||||||
/// 세션 및 누적 통계를 추적하는 모델
|
/// 세션 및 누적 통계를 추적하는 모델.
|
||||||
|
/// 세부 구현은 SessionStatistics와 CumulativeStatistics로 분리됨.
|
||||||
class GameStatistics {
|
class GameStatistics {
|
||||||
const GameStatistics({required this.session, required this.cumulative});
|
const GameStatistics({required this.session, required this.cumulative});
|
||||||
|
|
||||||
@@ -59,558 +67,3 @@ class GameStatistics {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 세션 통계 (Session Statistics)
|
|
||||||
///
|
|
||||||
/// 현재 게임 세션의 통계
|
|
||||||
class SessionStatistics {
|
|
||||||
const SessionStatistics({
|
|
||||||
required this.playTimeMs,
|
|
||||||
required this.monstersKilled,
|
|
||||||
required this.goldEarned,
|
|
||||||
required this.goldSpent,
|
|
||||||
required this.skillsUsed,
|
|
||||||
required this.criticalHits,
|
|
||||||
required this.maxCriticalStreak,
|
|
||||||
required this.currentCriticalStreak,
|
|
||||||
required this.totalDamageDealt,
|
|
||||||
required this.totalDamageTaken,
|
|
||||||
required this.potionsUsed,
|
|
||||||
required this.itemsSold,
|
|
||||||
required this.questsCompleted,
|
|
||||||
required this.deathCount,
|
|
||||||
required this.bossesDefeated,
|
|
||||||
required this.levelUps,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// 플레이 시간 (밀리초)
|
|
||||||
final int playTimeMs;
|
|
||||||
|
|
||||||
/// 처치한 몬스터 수
|
|
||||||
final int monstersKilled;
|
|
||||||
|
|
||||||
/// 획득한 골드 총량
|
|
||||||
final int goldEarned;
|
|
||||||
|
|
||||||
/// 소비한 골드 총량
|
|
||||||
final int goldSpent;
|
|
||||||
|
|
||||||
/// 사용한 스킬 횟수
|
|
||||||
final int skillsUsed;
|
|
||||||
|
|
||||||
/// 크리티컬 히트 횟수
|
|
||||||
final int criticalHits;
|
|
||||||
|
|
||||||
/// 최대 연속 크리티컬
|
|
||||||
final int maxCriticalStreak;
|
|
||||||
|
|
||||||
/// 현재 연속 크리티컬 (내부 추적용)
|
|
||||||
final int currentCriticalStreak;
|
|
||||||
|
|
||||||
/// 총 입힌 데미지
|
|
||||||
final int totalDamageDealt;
|
|
||||||
|
|
||||||
/// 총 받은 데미지
|
|
||||||
final int totalDamageTaken;
|
|
||||||
|
|
||||||
/// 사용한 물약 수
|
|
||||||
final int potionsUsed;
|
|
||||||
|
|
||||||
/// 판매한 아이템 수
|
|
||||||
final int itemsSold;
|
|
||||||
|
|
||||||
/// 완료한 퀘스트 수
|
|
||||||
final int questsCompleted;
|
|
||||||
|
|
||||||
/// 사망 횟수
|
|
||||||
final int deathCount;
|
|
||||||
|
|
||||||
/// 처치한 보스 수
|
|
||||||
final int bossesDefeated;
|
|
||||||
|
|
||||||
/// 레벨업 횟수
|
|
||||||
final int levelUps;
|
|
||||||
|
|
||||||
/// 플레이 시간 Duration
|
|
||||||
Duration get playTime => Duration(milliseconds: playTimeMs);
|
|
||||||
|
|
||||||
/// 플레이 시간 포맷 (HH:MM:SS)
|
|
||||||
String get formattedPlayTime {
|
|
||||||
final hours = playTime.inHours;
|
|
||||||
final minutes = playTime.inMinutes % 60;
|
|
||||||
final seconds = playTime.inSeconds % 60;
|
|
||||||
return '${hours.toString().padLeft(2, '0')}:'
|
|
||||||
'${minutes.toString().padLeft(2, '0')}:'
|
|
||||||
'${seconds.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 평균 DPS (damage per second)
|
|
||||||
double get averageDps {
|
|
||||||
if (playTimeMs <= 0) return 0;
|
|
||||||
return totalDamageDealt / (playTimeMs / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 킬당 평균 골드
|
|
||||||
double get goldPerKill {
|
|
||||||
if (monstersKilled <= 0) return 0;
|
|
||||||
return goldEarned / monstersKilled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 크리티컬 비율
|
|
||||||
double get criticalRate {
|
|
||||||
if (skillsUsed <= 0) return 0;
|
|
||||||
return criticalHits / skillsUsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 빈 세션 통계
|
|
||||||
factory SessionStatistics.empty() => const SessionStatistics(
|
|
||||||
playTimeMs: 0,
|
|
||||||
monstersKilled: 0,
|
|
||||||
goldEarned: 0,
|
|
||||||
goldSpent: 0,
|
|
||||||
skillsUsed: 0,
|
|
||||||
criticalHits: 0,
|
|
||||||
maxCriticalStreak: 0,
|
|
||||||
currentCriticalStreak: 0,
|
|
||||||
totalDamageDealt: 0,
|
|
||||||
totalDamageTaken: 0,
|
|
||||||
potionsUsed: 0,
|
|
||||||
itemsSold: 0,
|
|
||||||
questsCompleted: 0,
|
|
||||||
deathCount: 0,
|
|
||||||
bossesDefeated: 0,
|
|
||||||
levelUps: 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 이벤트 기록 메서드
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 몬스터 처치 기록
|
|
||||||
SessionStatistics recordKill({bool isBoss = false}) {
|
|
||||||
return copyWith(
|
|
||||||
monstersKilled: monstersKilled + 1,
|
|
||||||
bossesDefeated: isBoss ? bossesDefeated + 1 : bossesDefeated,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 골드 획득 기록
|
|
||||||
SessionStatistics recordGoldEarned(int amount) {
|
|
||||||
return copyWith(goldEarned: goldEarned + amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 골드 소비 기록
|
|
||||||
SessionStatistics recordGoldSpent(int amount) {
|
|
||||||
return copyWith(goldSpent: goldSpent + amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 스킬 사용 기록
|
|
||||||
SessionStatistics recordSkillUse({required bool isCritical}) {
|
|
||||||
final newCriticalStreak = isCritical ? currentCriticalStreak + 1 : 0;
|
|
||||||
final newMaxStreak = newCriticalStreak > maxCriticalStreak
|
|
||||||
? newCriticalStreak
|
|
||||||
: maxCriticalStreak;
|
|
||||||
|
|
||||||
return copyWith(
|
|
||||||
skillsUsed: skillsUsed + 1,
|
|
||||||
criticalHits: isCritical ? criticalHits + 1 : criticalHits,
|
|
||||||
currentCriticalStreak: newCriticalStreak,
|
|
||||||
maxCriticalStreak: newMaxStreak,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 데미지 기록
|
|
||||||
SessionStatistics recordDamage({int dealt = 0, int taken = 0}) {
|
|
||||||
return copyWith(
|
|
||||||
totalDamageDealt: totalDamageDealt + dealt,
|
|
||||||
totalDamageTaken: totalDamageTaken + taken,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 물약 사용 기록
|
|
||||||
SessionStatistics recordPotionUse() {
|
|
||||||
return copyWith(potionsUsed: potionsUsed + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 아이템 판매 기록
|
|
||||||
SessionStatistics recordItemSold(int count) {
|
|
||||||
return copyWith(itemsSold: itemsSold + count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 퀘스트 완료 기록
|
|
||||||
SessionStatistics recordQuestComplete() {
|
|
||||||
return copyWith(questsCompleted: questsCompleted + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 사망 기록
|
|
||||||
SessionStatistics recordDeath() {
|
|
||||||
return copyWith(deathCount: deathCount + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 레벨업 기록
|
|
||||||
SessionStatistics recordLevelUp() {
|
|
||||||
return copyWith(levelUps: levelUps + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 플레이 시간 업데이트
|
|
||||||
SessionStatistics updatePlayTime(int elapsedMs) {
|
|
||||||
return copyWith(playTimeMs: elapsedMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
SessionStatistics copyWith({
|
|
||||||
int? playTimeMs,
|
|
||||||
int? monstersKilled,
|
|
||||||
int? goldEarned,
|
|
||||||
int? goldSpent,
|
|
||||||
int? skillsUsed,
|
|
||||||
int? criticalHits,
|
|
||||||
int? maxCriticalStreak,
|
|
||||||
int? currentCriticalStreak,
|
|
||||||
int? totalDamageDealt,
|
|
||||||
int? totalDamageTaken,
|
|
||||||
int? potionsUsed,
|
|
||||||
int? itemsSold,
|
|
||||||
int? questsCompleted,
|
|
||||||
int? deathCount,
|
|
||||||
int? bossesDefeated,
|
|
||||||
int? levelUps,
|
|
||||||
}) {
|
|
||||||
return SessionStatistics(
|
|
||||||
playTimeMs: playTimeMs ?? this.playTimeMs,
|
|
||||||
monstersKilled: monstersKilled ?? this.monstersKilled,
|
|
||||||
goldEarned: goldEarned ?? this.goldEarned,
|
|
||||||
goldSpent: goldSpent ?? this.goldSpent,
|
|
||||||
skillsUsed: skillsUsed ?? this.skillsUsed,
|
|
||||||
criticalHits: criticalHits ?? this.criticalHits,
|
|
||||||
maxCriticalStreak: maxCriticalStreak ?? this.maxCriticalStreak,
|
|
||||||
currentCriticalStreak:
|
|
||||||
currentCriticalStreak ?? this.currentCriticalStreak,
|
|
||||||
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
|
|
||||||
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
|
|
||||||
potionsUsed: potionsUsed ?? this.potionsUsed,
|
|
||||||
itemsSold: itemsSold ?? this.itemsSold,
|
|
||||||
questsCompleted: questsCompleted ?? this.questsCompleted,
|
|
||||||
deathCount: deathCount ?? this.deathCount,
|
|
||||||
bossesDefeated: bossesDefeated ?? this.bossesDefeated,
|
|
||||||
levelUps: levelUps ?? this.levelUps,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// JSON 직렬화
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
'playTimeMs': playTimeMs,
|
|
||||||
'monstersKilled': monstersKilled,
|
|
||||||
'goldEarned': goldEarned,
|
|
||||||
'goldSpent': goldSpent,
|
|
||||||
'skillsUsed': skillsUsed,
|
|
||||||
'criticalHits': criticalHits,
|
|
||||||
'maxCriticalStreak': maxCriticalStreak,
|
|
||||||
'totalDamageDealt': totalDamageDealt,
|
|
||||||
'totalDamageTaken': totalDamageTaken,
|
|
||||||
'potionsUsed': potionsUsed,
|
|
||||||
'itemsSold': itemsSold,
|
|
||||||
'questsCompleted': questsCompleted,
|
|
||||||
'deathCount': deathCount,
|
|
||||||
'bossesDefeated': bossesDefeated,
|
|
||||||
'levelUps': levelUps,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// JSON 역직렬화
|
|
||||||
factory SessionStatistics.fromJson(Map<String, dynamic> json) {
|
|
||||||
return SessionStatistics(
|
|
||||||
playTimeMs: json['playTimeMs'] as int? ?? 0,
|
|
||||||
monstersKilled: json['monstersKilled'] as int? ?? 0,
|
|
||||||
goldEarned: json['goldEarned'] as int? ?? 0,
|
|
||||||
goldSpent: json['goldSpent'] as int? ?? 0,
|
|
||||||
skillsUsed: json['skillsUsed'] as int? ?? 0,
|
|
||||||
criticalHits: json['criticalHits'] as int? ?? 0,
|
|
||||||
maxCriticalStreak: json['maxCriticalStreak'] as int? ?? 0,
|
|
||||||
currentCriticalStreak: 0, // 세션간 유지 안 함
|
|
||||||
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
|
|
||||||
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
|
|
||||||
potionsUsed: json['potionsUsed'] as int? ?? 0,
|
|
||||||
itemsSold: json['itemsSold'] as int? ?? 0,
|
|
||||||
questsCompleted: json['questsCompleted'] as int? ?? 0,
|
|
||||||
deathCount: json['deathCount'] as int? ?? 0,
|
|
||||||
bossesDefeated: json['bossesDefeated'] as int? ?? 0,
|
|
||||||
levelUps: json['levelUps'] as int? ?? 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 누적 통계 (Cumulative Statistics)
|
|
||||||
///
|
|
||||||
/// 모든 게임 세션의 누적 통계
|
|
||||||
class CumulativeStatistics {
|
|
||||||
const CumulativeStatistics({
|
|
||||||
required this.totalPlayTimeMs,
|
|
||||||
required this.totalMonstersKilled,
|
|
||||||
required this.totalGoldEarned,
|
|
||||||
required this.totalGoldSpent,
|
|
||||||
required this.totalSkillsUsed,
|
|
||||||
required this.totalCriticalHits,
|
|
||||||
required this.bestCriticalStreak,
|
|
||||||
required this.totalDamageDealt,
|
|
||||||
required this.totalDamageTaken,
|
|
||||||
required this.totalPotionsUsed,
|
|
||||||
required this.totalItemsSold,
|
|
||||||
required this.totalQuestsCompleted,
|
|
||||||
required this.totalDeaths,
|
|
||||||
required this.totalBossesDefeated,
|
|
||||||
required this.totalLevelUps,
|
|
||||||
required this.highestLevel,
|
|
||||||
required this.highestGoldHeld,
|
|
||||||
required this.gamesCompleted,
|
|
||||||
required this.gamesStarted,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// 총 플레이 시간 (밀리초)
|
|
||||||
final int totalPlayTimeMs;
|
|
||||||
|
|
||||||
/// 총 처치한 몬스터 수
|
|
||||||
final int totalMonstersKilled;
|
|
||||||
|
|
||||||
/// 총 획득한 골드
|
|
||||||
final int totalGoldEarned;
|
|
||||||
|
|
||||||
/// 총 소비한 골드
|
|
||||||
final int totalGoldSpent;
|
|
||||||
|
|
||||||
/// 총 스킬 사용 횟수
|
|
||||||
final int totalSkillsUsed;
|
|
||||||
|
|
||||||
/// 총 크리티컬 히트 횟수
|
|
||||||
final int totalCriticalHits;
|
|
||||||
|
|
||||||
/// 최고 연속 크리티컬
|
|
||||||
final int bestCriticalStreak;
|
|
||||||
|
|
||||||
/// 총 입힌 데미지
|
|
||||||
final int totalDamageDealt;
|
|
||||||
|
|
||||||
/// 총 받은 데미지
|
|
||||||
final int totalDamageTaken;
|
|
||||||
|
|
||||||
/// 총 사용한 물약 수
|
|
||||||
final int totalPotionsUsed;
|
|
||||||
|
|
||||||
/// 총 판매한 아이템 수
|
|
||||||
final int totalItemsSold;
|
|
||||||
|
|
||||||
/// 총 완료한 퀘스트 수
|
|
||||||
final int totalQuestsCompleted;
|
|
||||||
|
|
||||||
/// 총 사망 횟수
|
|
||||||
final int totalDeaths;
|
|
||||||
|
|
||||||
/// 총 처치한 보스 수
|
|
||||||
final int totalBossesDefeated;
|
|
||||||
|
|
||||||
/// 총 레벨업 횟수
|
|
||||||
final int totalLevelUps;
|
|
||||||
|
|
||||||
/// 최고 달성 레벨
|
|
||||||
final int highestLevel;
|
|
||||||
|
|
||||||
/// 최대 보유 골드
|
|
||||||
final int highestGoldHeld;
|
|
||||||
|
|
||||||
/// 클리어한 게임 수
|
|
||||||
final int gamesCompleted;
|
|
||||||
|
|
||||||
/// 시작한 게임 수
|
|
||||||
final int gamesStarted;
|
|
||||||
|
|
||||||
/// 총 플레이 시간 Duration
|
|
||||||
Duration get totalPlayTime => Duration(milliseconds: totalPlayTimeMs);
|
|
||||||
|
|
||||||
/// 총 플레이 시간 포맷 (HH:MM:SS)
|
|
||||||
String get formattedTotalPlayTime {
|
|
||||||
final hours = totalPlayTime.inHours;
|
|
||||||
final minutes = totalPlayTime.inMinutes % 60;
|
|
||||||
final seconds = totalPlayTime.inSeconds % 60;
|
|
||||||
return '${hours.toString().padLeft(2, '0')}:'
|
|
||||||
'${minutes.toString().padLeft(2, '0')}:'
|
|
||||||
'${seconds.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 평균 게임당 플레이 시간
|
|
||||||
Duration get averagePlayTimePerGame {
|
|
||||||
if (gamesStarted <= 0) return Duration.zero;
|
|
||||||
return Duration(milliseconds: totalPlayTimeMs ~/ gamesStarted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 게임 완료율
|
|
||||||
double get completionRate {
|
|
||||||
if (gamesStarted <= 0) return 0;
|
|
||||||
return gamesCompleted / gamesStarted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 빈 누적 통계
|
|
||||||
factory CumulativeStatistics.empty() => const CumulativeStatistics(
|
|
||||||
totalPlayTimeMs: 0,
|
|
||||||
totalMonstersKilled: 0,
|
|
||||||
totalGoldEarned: 0,
|
|
||||||
totalGoldSpent: 0,
|
|
||||||
totalSkillsUsed: 0,
|
|
||||||
totalCriticalHits: 0,
|
|
||||||
bestCriticalStreak: 0,
|
|
||||||
totalDamageDealt: 0,
|
|
||||||
totalDamageTaken: 0,
|
|
||||||
totalPotionsUsed: 0,
|
|
||||||
totalItemsSold: 0,
|
|
||||||
totalQuestsCompleted: 0,
|
|
||||||
totalDeaths: 0,
|
|
||||||
totalBossesDefeated: 0,
|
|
||||||
totalLevelUps: 0,
|
|
||||||
highestLevel: 0,
|
|
||||||
highestGoldHeld: 0,
|
|
||||||
gamesCompleted: 0,
|
|
||||||
gamesStarted: 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// 세션 통계 병합
|
|
||||||
CumulativeStatistics mergeSession(SessionStatistics session) {
|
|
||||||
return CumulativeStatistics(
|
|
||||||
totalPlayTimeMs: totalPlayTimeMs + session.playTimeMs,
|
|
||||||
totalMonstersKilled: totalMonstersKilled + session.monstersKilled,
|
|
||||||
totalGoldEarned: totalGoldEarned + session.goldEarned,
|
|
||||||
totalGoldSpent: totalGoldSpent + session.goldSpent,
|
|
||||||
totalSkillsUsed: totalSkillsUsed + session.skillsUsed,
|
|
||||||
totalCriticalHits: totalCriticalHits + session.criticalHits,
|
|
||||||
bestCriticalStreak: session.maxCriticalStreak > bestCriticalStreak
|
|
||||||
? session.maxCriticalStreak
|
|
||||||
: bestCriticalStreak,
|
|
||||||
totalDamageDealt: totalDamageDealt + session.totalDamageDealt,
|
|
||||||
totalDamageTaken: totalDamageTaken + session.totalDamageTaken,
|
|
||||||
totalPotionsUsed: totalPotionsUsed + session.potionsUsed,
|
|
||||||
totalItemsSold: totalItemsSold + session.itemsSold,
|
|
||||||
totalQuestsCompleted: totalQuestsCompleted + session.questsCompleted,
|
|
||||||
totalDeaths: totalDeaths + session.deathCount,
|
|
||||||
totalBossesDefeated: totalBossesDefeated + session.bossesDefeated,
|
|
||||||
totalLevelUps: totalLevelUps + session.levelUps,
|
|
||||||
highestLevel: highestLevel, // 별도 업데이트 필요
|
|
||||||
highestGoldHeld: highestGoldHeld, // 별도 업데이트 필요
|
|
||||||
gamesCompleted: gamesCompleted, // 별도 업데이트 필요
|
|
||||||
gamesStarted: gamesStarted, // 별도 업데이트 필요
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 최고 레벨 업데이트
|
|
||||||
CumulativeStatistics updateHighestLevel(int level) {
|
|
||||||
if (level <= highestLevel) return this;
|
|
||||||
return copyWith(highestLevel: level);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 최대 골드 업데이트
|
|
||||||
CumulativeStatistics updateHighestGold(int gold) {
|
|
||||||
if (gold <= highestGoldHeld) return this;
|
|
||||||
return copyWith(highestGoldHeld: gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 새 게임 시작 기록
|
|
||||||
CumulativeStatistics recordGameStart() {
|
|
||||||
return copyWith(gamesStarted: gamesStarted + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 게임 클리어 기록
|
|
||||||
CumulativeStatistics recordGameComplete() {
|
|
||||||
return copyWith(gamesCompleted: gamesCompleted + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
CumulativeStatistics copyWith({
|
|
||||||
int? totalPlayTimeMs,
|
|
||||||
int? totalMonstersKilled,
|
|
||||||
int? totalGoldEarned,
|
|
||||||
int? totalGoldSpent,
|
|
||||||
int? totalSkillsUsed,
|
|
||||||
int? totalCriticalHits,
|
|
||||||
int? bestCriticalStreak,
|
|
||||||
int? totalDamageDealt,
|
|
||||||
int? totalDamageTaken,
|
|
||||||
int? totalPotionsUsed,
|
|
||||||
int? totalItemsSold,
|
|
||||||
int? totalQuestsCompleted,
|
|
||||||
int? totalDeaths,
|
|
||||||
int? totalBossesDefeated,
|
|
||||||
int? totalLevelUps,
|
|
||||||
int? highestLevel,
|
|
||||||
int? highestGoldHeld,
|
|
||||||
int? gamesCompleted,
|
|
||||||
int? gamesStarted,
|
|
||||||
}) {
|
|
||||||
return CumulativeStatistics(
|
|
||||||
totalPlayTimeMs: totalPlayTimeMs ?? this.totalPlayTimeMs,
|
|
||||||
totalMonstersKilled: totalMonstersKilled ?? this.totalMonstersKilled,
|
|
||||||
totalGoldEarned: totalGoldEarned ?? this.totalGoldEarned,
|
|
||||||
totalGoldSpent: totalGoldSpent ?? this.totalGoldSpent,
|
|
||||||
totalSkillsUsed: totalSkillsUsed ?? this.totalSkillsUsed,
|
|
||||||
totalCriticalHits: totalCriticalHits ?? this.totalCriticalHits,
|
|
||||||
bestCriticalStreak: bestCriticalStreak ?? this.bestCriticalStreak,
|
|
||||||
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
|
|
||||||
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
|
|
||||||
totalPotionsUsed: totalPotionsUsed ?? this.totalPotionsUsed,
|
|
||||||
totalItemsSold: totalItemsSold ?? this.totalItemsSold,
|
|
||||||
totalQuestsCompleted: totalQuestsCompleted ?? this.totalQuestsCompleted,
|
|
||||||
totalDeaths: totalDeaths ?? this.totalDeaths,
|
|
||||||
totalBossesDefeated: totalBossesDefeated ?? this.totalBossesDefeated,
|
|
||||||
totalLevelUps: totalLevelUps ?? this.totalLevelUps,
|
|
||||||
highestLevel: highestLevel ?? this.highestLevel,
|
|
||||||
highestGoldHeld: highestGoldHeld ?? this.highestGoldHeld,
|
|
||||||
gamesCompleted: gamesCompleted ?? this.gamesCompleted,
|
|
||||||
gamesStarted: gamesStarted ?? this.gamesStarted,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// JSON 직렬화
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
'totalPlayTimeMs': totalPlayTimeMs,
|
|
||||||
'totalMonstersKilled': totalMonstersKilled,
|
|
||||||
'totalGoldEarned': totalGoldEarned,
|
|
||||||
'totalGoldSpent': totalGoldSpent,
|
|
||||||
'totalSkillsUsed': totalSkillsUsed,
|
|
||||||
'totalCriticalHits': totalCriticalHits,
|
|
||||||
'bestCriticalStreak': bestCriticalStreak,
|
|
||||||
'totalDamageDealt': totalDamageDealt,
|
|
||||||
'totalDamageTaken': totalDamageTaken,
|
|
||||||
'totalPotionsUsed': totalPotionsUsed,
|
|
||||||
'totalItemsSold': totalItemsSold,
|
|
||||||
'totalQuestsCompleted': totalQuestsCompleted,
|
|
||||||
'totalDeaths': totalDeaths,
|
|
||||||
'totalBossesDefeated': totalBossesDefeated,
|
|
||||||
'totalLevelUps': totalLevelUps,
|
|
||||||
'highestLevel': highestLevel,
|
|
||||||
'highestGoldHeld': highestGoldHeld,
|
|
||||||
'gamesCompleted': gamesCompleted,
|
|
||||||
'gamesStarted': gamesStarted,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// JSON 역직렬화
|
|
||||||
factory CumulativeStatistics.fromJson(Map<String, dynamic> json) {
|
|
||||||
return CumulativeStatistics(
|
|
||||||
totalPlayTimeMs: json['totalPlayTimeMs'] as int? ?? 0,
|
|
||||||
totalMonstersKilled: json['totalMonstersKilled'] as int? ?? 0,
|
|
||||||
totalGoldEarned: json['totalGoldEarned'] as int? ?? 0,
|
|
||||||
totalGoldSpent: json['totalGoldSpent'] as int? ?? 0,
|
|
||||||
totalSkillsUsed: json['totalSkillsUsed'] as int? ?? 0,
|
|
||||||
totalCriticalHits: json['totalCriticalHits'] as int? ?? 0,
|
|
||||||
bestCriticalStreak: json['bestCriticalStreak'] as int? ?? 0,
|
|
||||||
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
|
|
||||||
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
|
|
||||||
totalPotionsUsed: json['totalPotionsUsed'] as int? ?? 0,
|
|
||||||
totalItemsSold: json['totalItemsSold'] as int? ?? 0,
|
|
||||||
totalQuestsCompleted: json['totalQuestsCompleted'] as int? ?? 0,
|
|
||||||
totalDeaths: json['totalDeaths'] as int? ?? 0,
|
|
||||||
totalBossesDefeated: json['totalBossesDefeated'] as int? ?? 0,
|
|
||||||
totalLevelUps: json['totalLevelUps'] as int? ?? 0,
|
|
||||||
highestLevel: json['highestLevel'] as int? ?? 0,
|
|
||||||
highestGoldHeld: json['highestGoldHeld'] as int? ?? 0,
|
|
||||||
gamesCompleted: json['gamesCompleted'] as int? ?? 0,
|
|
||||||
gamesStarted: json['gamesStarted'] as int? ?? 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
28
lib/src/core/model/inventory.dart
Normal file
28
lib/src/core/model/inventory.dart
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/// 인벤토리 아이템 엔트리 (Inventory Entry)
|
||||||
|
class InventoryEntry {
|
||||||
|
const InventoryEntry({required this.name, required this.count});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
InventoryEntry copyWith({String? name, int? count}) {
|
||||||
|
return InventoryEntry(name: name ?? this.name, count: count ?? this.count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 인벤토리 (Inventory)
|
||||||
|
///
|
||||||
|
/// 골드와 아이템 목록을 관리
|
||||||
|
class Inventory {
|
||||||
|
const Inventory({required this.gold, required this.items});
|
||||||
|
|
||||||
|
final int gold;
|
||||||
|
final List<InventoryEntry> items;
|
||||||
|
|
||||||
|
/// 초기 골드 1000 지급 (캐릭터 생성 시)
|
||||||
|
factory Inventory.empty() => const Inventory(gold: 1000, items: []);
|
||||||
|
|
||||||
|
Inventory copyWith({int? gold, List<InventoryEntry>? items}) {
|
||||||
|
return Inventory(gold: gold ?? this.gold, items: items ?? this.items);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user