Compare commits
53 Commits
d07a0c5554
...
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 |
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/ # 엔진 테스트만
|
||||
```
|
||||
96
CHANGELOG.md
96
CHANGELOG.md
@@ -1,46 +1,70 @@
|
||||
# Changelog
|
||||
|
||||
프로젝트의 주요 변경 사항을 기록합니다.
|
||||
형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Refactored (리팩토링)
|
||||
|
||||
#### GameSessionController 분할 (SRP 개선)
|
||||
- 920 LOC → 526 LOC (43% 감소)
|
||||
- 5개 매니저로 책임 분리:
|
||||
- `GameStatisticsManager` - 세션/누적 통계 추적
|
||||
- `SpeedBoostManager` - 광고 배속 부스트 기능
|
||||
- `ReturnRewardsManager` - 복귀 보상 기능
|
||||
- `ResurrectionManager` - 사망/부활 처리
|
||||
- `HallOfFameManager` - 명예의 전당 관리
|
||||
|
||||
#### ProgressService 메서드 분할
|
||||
- `tick()`: 350 LOC → 80 LOC (8개 헬퍼 메서드)
|
||||
- `_generateNextTask()`: 200 LOC → 35 LOC (6개 헬퍼 메서드)
|
||||
|
||||
#### GamePlayScreen 메서드 분할
|
||||
- `build()`: 300 LOC → 15 LOC (5개 헬퍼 메서드)
|
||||
|
||||
#### Clean Architecture 개선
|
||||
- `MonsterGrade.displayColor` (Color) → `displayColorCode` (int)
|
||||
- Domain 레이어에서 Flutter 의존성 제거
|
||||
|
||||
### Fixed (버그 수정)
|
||||
|
||||
#### Analyzer 경고 정리
|
||||
- 미사용 import 제거 (`panel_header.dart`)
|
||||
- 미사용 필드 제거 (`new_character_screen.dart`)
|
||||
- JsonKey 경고 억제 (`equipment_item.dart`, `monetization_state.dart`)
|
||||
### Added
|
||||
- AdMob 미디에이션 지원 준비 (AppLovin MAX)
|
||||
|
||||
---
|
||||
|
||||
## 버전 표기 규칙
|
||||
## [1.1.0] - 2026-03-30
|
||||
|
||||
- `Added`: 새로운 기능 추가
|
||||
- `Changed`: 기존 기능 변경
|
||||
- `Deprecated`: 곧 제거될 기능
|
||||
- `Removed`: 제거된 기능
|
||||
- `Fixed`: 버그 수정
|
||||
- `Security`: 보안 관련 수정
|
||||
- `Refactored`: 코드 구조 개선 (기능 변화 없음)
|
||||
### 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개 언어 지원
|
||||
- 오프라인 완전 동작
|
||||
|
||||
108
CLAUDE.md
108
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 아트 비주얼과 자동 전투 시스템이 특징이며, 네트워크 기능은 제외됩니다.
|
||||
|
||||
## 빌드 및 실행
|
||||
|
||||
@@ -28,28 +28,50 @@ flutter test
|
||||
```
|
||||
lib/
|
||||
├── 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/
|
||||
├── app.dart # MaterialApp 설정
|
||||
├── core/
|
||||
│ ├── engine/ # 게임 루프 및 진행 로직
|
||||
│ │ ├── progress_loop.dart # 타이머 기반 메인 루프 (원본 200ms)
|
||||
│ │ ├── progress_loop.dart # 타이머 기반 메인 루프
|
||||
│ │ ├── progress_service.dart # 틱 처리, 경험치/레벨업 로직
|
||||
│ │ ├── game_mutations.dart # 상태 변경 함수
|
||||
│ │ └── reward_service.dart # 보상 처리
|
||||
│ ├── model/
|
||||
│ │ ├── game_state.dart # 핵심 상태: Traits, Stats, Inventory, Equipment, SpellBook, ProgressState, QueueState
|
||||
│ │ ├── pq_config.dart # Config 데이터 접근
|
||||
│ │ ├── equipment_slot.dart # 장비 슬롯 정의
|
||||
│ │ └── save_data.dart # 저장 데이터 구조
|
||||
│ ├── storage/ # 세이브 파일 처리
|
||||
│ └── util/
|
||||
│ ├── deterministic_random.dart # 결정론적 RNG (재현 가능)
|
||||
│ ├── pq_logic.dart # 원본 로직 포팅 (odds, randSign 등)
|
||||
│ └── roman.dart # 로마 숫자 변환
|
||||
└── features/
|
||||
├── front/front_screen.dart # 임시 프론트 화면
|
||||
└── game/game_session_controller.dart # 게임 세션 관리
|
||||
│ │ ├── reward_service.dart # 보상 처리
|
||||
│ │ ├── combat_calculator.dart # 전투 계산
|
||||
│ │ ├── combat_tick_service.dart # 전투 틱 처리
|
||||
│ │ ├── arena_service.dart # 아레나 시스템
|
||||
│ │ ├── skill_service.dart # 스킬 시스템
|
||||
│ │ ├── item_service.dart # 아이템 처리
|
||||
│ │ ├── potion_service.dart # 포션 시스템
|
||||
│ │ ├── shop_service.dart # 상점 시스템
|
||||
│ │ ├── story_service.dart # 스토리 진행
|
||||
│ │ └── ... # 기타 서비스
|
||||
│ ├── model/ # 게임 상태 및 데이터 모델
|
||||
│ ├── infrastructure/ # 외부 서비스 (광고, IAP 등)
|
||||
│ ├── audio/ # 오디오 서비스
|
||||
│ ├── storage/ # 세이브/설정 저장소
|
||||
│ ├── notification/ # 알림 서비스
|
||||
│ └── util/ # 유틸리티 (RNG, 로직 헬퍼 등)
|
||||
├── features/
|
||||
│ ├── front/ # 타이틀/세이브 선택 화면
|
||||
│ ├── new_character/ # 캐릭터 생성 화면
|
||||
│ ├── game/ # 게임 진행 화면 (메인)
|
||||
│ │ ├── controllers/ # 전투 로그, 오디오 컨트롤러
|
||||
│ │ ├── managers/ # 통계, 부활, 속도 부스트 등
|
||||
│ │ ├── pages/ # 탭별 페이지 (장비, 인벤토리, 퀘스트 등)
|
||||
│ │ └── widgets/ # UI 위젯
|
||||
│ ├── arena/ # 아레나 전투 화면
|
||||
│ ├── hall_of_fame/ # 명예의 전당
|
||||
│ └── settings/ # 설정 화면
|
||||
└── shared/ # 공통 테마/위젯
|
||||
|
||||
example/pq/ # Delphi 원본 소스 (참조용, 빌드 대상 아님)
|
||||
test/ # 단위/위젯 테스트
|
||||
@@ -69,10 +91,9 @@ test/ # 단위/위젯 테스트
|
||||
|
||||
## 핵심 규칙
|
||||
|
||||
### 원본 충실도
|
||||
- `example/pq/` 내 Delphi 소스의 알고리즘/데이터를 100% 동일하게 포팅
|
||||
- 원본 로직 변경 필요 시 반드시 사용자 승인 필요
|
||||
- 새로운 기능, 값, 처리 로직 추가 금지 (디버깅 로그 예외)
|
||||
### 원본 참조 정책
|
||||
- `example/pq/`는 참조용으로 유지
|
||||
- 원본 알고리즘은 참고하되 독자적 확장/수정 허용
|
||||
|
||||
### 데이터 관리
|
||||
- 정적 데이터(몬스터, 아이템, 주문 등)는 `Config.dfm`에서 추출하여 JSON/Dart const로 관리
|
||||
@@ -87,11 +108,13 @@ test/ # 단위/위젯 테스트
|
||||
- SRP(Single Responsibility Principle) 준수
|
||||
|
||||
### 화면 구성
|
||||
- 2개 화면만 사용: 캐릭터 생성 화면, 게임 진행 화면
|
||||
- 주요 화면: 프론트, 캐릭터 생성, 게임 진행, 아레나, 명예의 전당, 설정
|
||||
- 화면 내 요소는 위젯 단위로 분리
|
||||
|
||||
## 원본 소스 참조 (example/pq/)
|
||||
|
||||
> 참고용으로만 사용. 원본 로직을 그대로 따를 의무는 없음.
|
||||
|
||||
| 파일 | 핵심 함수/라인 | 역할 |
|
||||
|------|----------------|------|
|
||||
| `Main.pas:523-1040` | `MonsterTask` | 전투/전리품/레벨업 |
|
||||
@@ -105,7 +128,6 @@ test/ # 단위/위젯 테스트
|
||||
- `pubspec.yaml` 의존성 변경
|
||||
- 플랫폼 빌드 설정 (Android/iOS/desktop)
|
||||
- 네트워크 접근 도입
|
||||
- 원본 데이터/알고리즘 수정
|
||||
- 대규모 파일 삭제 또는 구조 변경
|
||||
|
||||
## 커밋 규칙
|
||||
@@ -117,3 +139,43 @@ type(scope): 한국어 설명
|
||||
```
|
||||
|
||||
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 분석
|
||||
|
||||
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.
|
||||
- `example/pq/` – original Delphi source/assets (reference only, not built).
|
||||
## 스크린샷
|
||||
|
||||
<!-- TODO: 스크린샷 추가 -->
|
||||
| 타이틀 | 캐릭터 생성 | 게임 진행 |
|
||||
|--------|-------------|-----------|
|
||||
|  |  |  |
|
||||
|
||||
| 아레나 | 명예의 전당 | 설정 |
|
||||
|--------|-------------|------|
|
||||
|  |  |  |
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- **방치형 자동 전투** -- 전투, 레벨업, 퀘스트, 스토리가 자동으로 진행
|
||||
- **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
|
||||
# 의존성 설치
|
||||
flutter pub get
|
||||
|
||||
# 코드 생성 (freezed/json_serializable)
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
# 실행 (-d macos, -d chrome, -d android 등)
|
||||
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 .
|
||||
flutter analyze
|
||||
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-inference: true
|
||||
strict-raw-types: true
|
||||
exclude:
|
||||
- "**/*.freezed.dart"
|
||||
- "**/*.g.dart"
|
||||
|
||||
linter:
|
||||
# Keep the rule set lean; we will tighten as the engine port stabilizes.
|
||||
|
||||
@@ -48,6 +48,12 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
|
||||
34
android/app/proguard-rules.pro
vendored
Normal file
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">
|
||||
<!-- IAP 결제 권한 -->
|
||||
<!-- AdMob 광고 로드에 필요 -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- IAP 결제(billing) 권한 -->
|
||||
<uses-permission android:name="com.android.vending.BILLING" />
|
||||
|
||||
<application
|
||||
android:label="asciineverdie"
|
||||
android:label="ASCII Never Die"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<!-- Copyright Protection -->
|
||||
|
||||
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.
|
||||
@@ -10,49 +10,35 @@
|
||||
|
||||
| 영역 | 점수 | CRITICAL | HIGH | MEDIUM | LOW |
|
||||
|------|------|----------|------|--------|-----|
|
||||
| 보안 | **4/10** | 2 | 1 | 1 | - |
|
||||
| 출시 준비 | **3/10** | 7 | 4 | 5 | - |
|
||||
| 사업/수익화 | **4/10** | 5 | 1 | 1 | 1 |
|
||||
| 코드 품질 | **7/10** | - | 3 | 3 | 1 |
|
||||
| 빌드/테스트 | **7/10** | - | 1 | 2 | - |
|
||||
| 로컬라이제이션 | **5/10** | 5 | 3 | 4 | - |
|
||||
| 원본 충실도 | **특수** | 1 | - | - | - |
|
||||
| 보안 | **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 이슈 20건 해결 필요.**
|
||||
**종합 판정: 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. 보안 - CRITICAL 이슈 발견
|
||||
## 1. 보안
|
||||
|
||||
### 1.1 CRITICAL
|
||||
### 1.1 해당 없음 (소유자 확인 완료)
|
||||
|
||||
| # | 이슈 | 위치 | 영향 |
|
||||
|---|------|------|------|
|
||||
| S1 | **JKS 키스토어가 Git에 추적 중** | `doc/key/askiineverdie.jks` | 앱 위조 서명 가능, 저장소 접근자 전원 노출 |
|
||||
| S2 | **key.properties 평문 비밀번호 Git 노출** | `android/key.properties` (storePassword=askiineverdie) | 키스토어 비밀번호 완전 노출 |
|
||||
| # | 이슈 | 소유자 판단 |
|
||||
|---|------|------------|
|
||||
| ~~S1~~ | ~~JKS 키스토어가 Git에 추적 중~~ | **해당 없음** - 개인 비공개 저장소이므로 문제 없음 |
|
||||
| ~~S2~~ | ~~key.properties 평문 비밀번호 Git 노출~~ | **해당 없음** - 개인 비공개 저장소이므로 문제 없음 |
|
||||
|
||||
### 1.2 즉시 조치 방법
|
||||
> **참고**: 저장소가 공개(public)로 전환되거나 팀 협업으로 확장될 경우 재검토 필요
|
||||
|
||||
```bash
|
||||
# 1. .gitignore에 추가
|
||||
*.jks
|
||||
*.keystore
|
||||
android/key.properties
|
||||
doc/key/
|
||||
*.env
|
||||
### 1.2 WARNING
|
||||
|
||||
# 2. Git 추적 해제
|
||||
git rm --cached doc/key/askiineverdie.jks
|
||||
git rm --cached android/key.properties
|
||||
|
||||
# 3. Git 히스토리에서 제거 (BFG Repo-Cleaner 권장)
|
||||
# 4. 키스토어를 저장소 외부 안전한 위치로 이동
|
||||
# 5. CI/CD 시크릿으로 비밀번호 관리
|
||||
```
|
||||
|
||||
### 1.3 WARNING
|
||||
|
||||
- `.gitignore`에 `*.jks`, `*.keystore`, `key.properties`, `*.env` 패턴 없음
|
||||
- `.vscode/`, `PLAN.md`가 추적되지 않은 상태로 존재
|
||||
|
||||
### 1.4 양호 항목
|
||||
@@ -68,28 +54,28 @@ git rm --cached android/key.properties
|
||||
|
||||
---
|
||||
|
||||
## 2. 출시 준비 상태 - 7개 CRITICAL
|
||||
## 2. 출시 준비 상태 - ~~7개~~ 0개 CRITICAL (모두 해결)
|
||||
|
||||
### 2.1 CRITICAL (출시 차단)
|
||||
|
||||
| # | 이슈 | 상세 |
|
||||
|---|------|------|
|
||||
| R1 | **iOS Bundle ID = `com.example.asciineverdie`** | App Store 제출 불가. `com.naturebridgeai.asciineverdie`로 변경 필요 |
|
||||
| R2 | **macOS Bundle ID = `com.example.asciineverdie`** | Mac App Store 제출 불가. 동일 변경 필요 |
|
||||
| R3 | **iOS DEVELOPMENT_TEAM 미설정** | 서명 불가, Xcode에서 Team ID 설정 필요 |
|
||||
| R4 | **정치적 문구가 iOS/Android 메타데이터에 포함** | `NSHumanReadableCopyright`: `© 2025 naturebridgeai 天安門 六四事件 法輪功 李洪志 Free Tibet` - 앱스토어 심사 즉각 거부 |
|
||||
| R5 | **Android 릴리즈에 INTERNET 권한 누락** | AdMob이 릴리즈 빌드에서 동작 불가 (debug/profile에만 존재) |
|
||||
| R6 | **iOS `GADApplicationIdentifier` 누락** | AdMob 초기화 시 iOS 앱 크래시 |
|
||||
| ~~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 | 앱 이름 플랫폼별 불일치 | Android: `asciineverdie`, iOS: `Asciineverdie`, 마케팅: `ASCII Never Die` |
|
||||
| R9 | macOS Release entitlements에 네트워크 권한 없음 | AdMob 동작 불가 |
|
||||
| R10 | Android ProGuard/R8 미설정 | 코드 난독화 미적용 |
|
||||
| R11 | macOS PRODUCT_COPYRIGHT = `Copyright 2025 com.example` | 기본값 미수정 |
|
||||
| ~~R8~~ | ~~앱 이름 플랫폼별 불일치~~ | **수정 완료** - 전 플랫폼 `ASCII Never Die`로 통일 |
|
||||
| ~~R9~~ | ~~macOS Release entitlements에 네트워크 권한 없음~~ | **수정 완료** - `com.apple.security.network.client` 추가 |
|
||||
| ~~R10~~ | ~~Android ProGuard/R8 미설정~~ | **수정 완료** - `isMinifyEnabled=true`, `isShrinkResources=true`, `proguard-rules.pro` 추가 |
|
||||
| ~~R11~~ | ~~macOS PRODUCT_COPYRIGHT = `Copyright 2025 com.example`~~ | **수정 완료** - `Copyright © 2025 naturebridgeai`로 변경 |
|
||||
|
||||
### 2.3 MEDIUM
|
||||
|
||||
@@ -105,9 +91,13 @@ git rm --cached android/key.properties
|
||||
|
||||
| 항목 | 설정값 | 상태 |
|
||||
|------|--------|------|
|
||||
| CFBundleDisplayName | `Asciineverdie` | 수정 필요 |
|
||||
| PRODUCT_BUNDLE_IDENTIFIER | `com.example.asciineverdie` | **CRITICAL** |
|
||||
| DEVELOPMENT_TEAM | 미설정 | **CRITICAL** |
|
||||
| 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 템플릿 | 개선 권장 |
|
||||
@@ -117,19 +107,22 @@ git rm --cached android/key.properties
|
||||
| 항목 | 설정값 | 상태 |
|
||||
|------|--------|------|
|
||||
| applicationId | `com.naturebridgeai.asciineverdie` | OK |
|
||||
| android:label | `ASCII Never Die` | **수정 완료** |
|
||||
| 릴리즈 서명 | key.properties 참조 | OK |
|
||||
| AdMob App ID | `ca-app-pub-6691216385521068~8216990571` | OK |
|
||||
| 앱 아이콘 | mdpi~xxxhdpi + Adaptive Icon | OK |
|
||||
| INTERNET 권한 | 릴리즈 미설정 | **CRITICAL** |
|
||||
| ProGuard/R8 | 미설정 | HIGH |
|
||||
| INTERNET 권한 | main AndroidManifest에 추가 | **수정 완료** |
|
||||
| ProGuard/R8 | `isMinifyEnabled=true`, `proguard-rules.pro` | **수정 완료** |
|
||||
|
||||
#### macOS
|
||||
|
||||
| 항목 | 설정값 | 상태 |
|
||||
|------|--------|------|
|
||||
| PRODUCT_BUNDLE_IDENTIFIER | `com.example.asciineverdie` | **CRITICAL** |
|
||||
| PRODUCT_BUNDLE_IDENTIFIER | `com.naturebridgeai.asciineverdie` | **수정 완료** |
|
||||
| PRODUCT_NAME | `ASCII Never Die` | **수정 완료** |
|
||||
| PRODUCT_COPYRIGHT | `Copyright © 2025 naturebridgeai` | **수정 완료** |
|
||||
| Sandbox | 활성화 | OK |
|
||||
| 네트워크 권한 (Release) | 미설정 | HIGH |
|
||||
| 네트워크 권한 (Release) | `network.client` 추가 | **수정 완료** |
|
||||
| MACOSX_DEPLOYMENT_TARGET | `10.15` | OK |
|
||||
| 앱 아이콘 | 16~1024px 존재 | OK |
|
||||
|
||||
@@ -143,19 +136,19 @@ git rm --cached android/key.properties
|
||||
|
||||
| 수익원 | 코드 구현 | 프로덕션 준비 | 준비도 |
|
||||
|--------|----------|-------------|--------|
|
||||
| 리워드 광고 (부활/되돌리기) | 구현됨 (`ad_service.dart`) | ID 미설정 | 60% |
|
||||
| 인터스티셜 광고 (충전/속도업) | 구현됨 | ID 미설정 | 60% |
|
||||
| 리워드 광고 (부활/되돌리기) | 구현됨 (`ad_service.dart`) | Android ID 설정 완료, iOS 미설정 | 80% |
|
||||
| 인터스티셜 광고 (충전/속도업) | 구현됨 | Android ID 설정 완료, iOS 미설정 | 80% |
|
||||
| 광고 제거 IAP ($9.99) | 구현됨 (`iap_service.dart`) | 스토어 상품 미등록 | 50% |
|
||||
|
||||
### 3.2 CRITICAL
|
||||
|
||||
| # | 이슈 |
|
||||
|---|------|
|
||||
| B1 | 프로덕션 광고 단위 ID가 모두 `ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX` 플레이스홀더 (`ad_service.dart:74-82`) |
|
||||
| B2 | iOS AdMob Info.plist 설정 누락 (GADApplicationIdentifier, SKAdNetworkItems, NSUserTrackingUsageDescription) |
|
||||
| B1 | 프로덕션 광고 단위 ID - **Android 완료**, iOS 플레이스홀더 잔여 (`ad_service.dart:77,81`) |
|
||||
| ~~B2~~ | ~~iOS AdMob Info.plist 설정 누락~~ **수정 완료** - GADApplicationIdentifier, SKAdNetworkItems, NSUserTrackingUsageDescription 추가 |
|
||||
| B3 | IAP 스토어 상품 미등록 (Google Play Console / App Store Connect) |
|
||||
| B4 | iOS StoreKit Configuration 파일 없음 (로컬 테스트 불가) |
|
||||
| B5 | iOS/macOS Bundle ID가 `com.example` (스토어 연동 불가) |
|
||||
| ~~B5~~ | ~~iOS/macOS Bundle ID가 `com.example`~~ **수정 완료** - `com.naturebridgeai.asciineverdie`로 변경됨 |
|
||||
|
||||
### 3.3 앱스토어 메타데이터
|
||||
|
||||
@@ -183,8 +176,8 @@ git rm --cached android/key.properties
|
||||
| 플랫폼 | Bundle ID | 상태 |
|
||||
|--------|-----------|------|
|
||||
| Android | `com.naturebridgeai.asciineverdie` | OK |
|
||||
| iOS | `com.example.asciineverdie` | **CRITICAL - 변경 필요** |
|
||||
| macOS | `com.example.asciineverdie` | **CRITICAL - 변경 필요** |
|
||||
| iOS | `com.naturebridgeai.asciineverdie` | **수정 완료** |
|
||||
| macOS | `com.naturebridgeai.asciineverdie` | **수정 완료** |
|
||||
|
||||
---
|
||||
|
||||
@@ -195,18 +188,11 @@ git rm --cached android/key.properties
|
||||
| 단계 | 결과 | 상세 |
|
||||
|------|------|------|
|
||||
| `flutter pub get` | **통과** | 의존성 정상 설치, 31개 패키지 업데이트 가능 |
|
||||
| `dart format --set-exit-if-changed .` | **실패** | 210개 중 **42개 파일** 포맷 미준수 (자동 수정됨) |
|
||||
| `flutter analyze` | **통과** (info 56건) | error 0, warning 0, info 56 (모두 스타일 수준) |
|
||||
| `flutter test` | **실패** (1건) | 104 통과 / **1 실패** |
|
||||
| `dart format --set-exit-if-changed .` | **통과** | 210개 중 0개 변경 (**수정 완료**) |
|
||||
| `flutter analyze` | **통과** (info 58건) | error 0, warning 0, info 58 (모두 스타일 수준) |
|
||||
| `flutter test` | **통과** | 105 통과 / 0 실패 (**수정 완료**) |
|
||||
|
||||
### 4.2 포맷 미준수 주요 파일
|
||||
|
||||
- `lib/data/game_text_l10n.dart`
|
||||
- `lib/src/core/engine/` 하위 다수 (act_progression_service, character_roll_service, chest_service, combat_tick_service 등)
|
||||
- `lib/src/core/model/` 하위 (combat_stats, item_stats, monetization_state, potion, treasure_chest)
|
||||
- `lib/src/features/game/` 하위 다수 (layouts, managers, pages, widgets)
|
||||
- `lib/src/features/new_character/` 하위
|
||||
- `test/` 하위 4개 파일
|
||||
### ~~4.2 포맷 미준수 주요 파일~~ - **수정 완료** (42개 파일 자동 포맷 적용됨)
|
||||
|
||||
### 4.3 정적분석 이슈 (56건 info)
|
||||
|
||||
@@ -218,51 +204,49 @@ git rm --cached android/key.properties
|
||||
| `avoid_print` | ~30 | `test/core/engine/gcd_simulation_test.dart` |
|
||||
| `prefer_interpolation_to_compose_strings` | 4 | 같은 테스트 파일 |
|
||||
|
||||
### 4.4 실패 테스트
|
||||
### ~~4.4 실패 테스트~~ - **수정 완료**
|
||||
|
||||
- **파일**: `test/core/engine/skill_service_test.dart:563`
|
||||
- **테스트**: `SkillService useBuffSkill 버프 적용`
|
||||
- **Expected**: `0.25`, **Actual**: `0.15`
|
||||
- **원인**: 버프 스킬 적용 비율 값 불일치
|
||||
- ~~**파일**: `test/core/engine/skill_service_test.dart:563`~~
|
||||
- **원인**: `SkillData.debugMode`의 `atkModifier`가 0.25→0.15, `mpCost`가 100→140으로 변경되었으나 테스트가 이전 값을 기대
|
||||
- **수정**: 테스트 기대값을 현재 데이터에 맞게 업데이트 (0.15, mpCurrent 10)
|
||||
|
||||
---
|
||||
|
||||
## 5. 코드 품질
|
||||
|
||||
### 5.1 Clean Architecture 위반 (MEDIUM)
|
||||
### ~~5.1 Clean Architecture 위반~~ - **수정 완료**
|
||||
|
||||
`core/` 레이어에 Flutter UI 의존성 존재 (Domain은 프레임워크 무관해야 함):
|
||||
~~`core/` 레이어에 Flutter UI 의존성 존재~~
|
||||
|
||||
| 파일 | 문제 |
|
||||
|------|------|
|
||||
| `core/constants/ascii_colors.dart:1` | `import 'package:flutter/material.dart'` + `BuildContext` 파라미터 |
|
||||
| `core/l10n/game_data_l10n.dart:5` | `import 'package:flutter/widgets.dart'` + `BuildContext` 사용 |
|
||||
| `core/animation/canvas/ascii_canvas_painter.dart:1` | `import 'package:flutter/material.dart'` |
|
||||
| `core/animation/canvas/ascii_canvas_widget.dart:1` | `import 'package:flutter/material.dart'` |
|
||||
| `core/animation/ascii_animation_data.dart:1` | `import 'package:flutter/material.dart'` |
|
||||
**수정 내용**: `core/animation/`, `core/constants/ascii_colors.dart`, `core/l10n/game_data_l10n.dart` 등 Flutter UI 의존 파일 19개를 `shared/` 디렉토리로 이동. `core/` 레이어는 순수 Dart만 유지.
|
||||
|
||||
**권장**: `core/animation/`, `core/constants/`, `core/l10n/` 일부를 `shared/` 또는 `features/`로 이동
|
||||
| 이동 항목 | 이동 전 | 이동 후 |
|
||||
|-----------|---------|---------|
|
||||
| animation (11개 파일) | `core/animation/` | `shared/animation/` |
|
||||
| ascii_colors.dart | `core/constants/` | `shared/theme/` |
|
||||
| game_data_l10n.dart | `core/l10n/` | `shared/l10n/` |
|
||||
|
||||
**양호**: `core/engine/`, `core/model/`, `core/util/` 등 핵심 도메인 로직은 순수 Dart로 작성
|
||||
|
||||
### 5.2 SRP 위반 - 대형 파일 (HIGH)
|
||||
### 5.2 SRP 위반 - 대형 파일 - **부분 수정 완료**
|
||||
|
||||
| 파일 | LOC | 권장 |
|
||||
|------|-----|------|
|
||||
| `features/game/game_play_screen.dart` | **1,536** | 위젯별 분리 |
|
||||
| `core/animation/canvas/canvas_battle_composer.dart` | **1,475** | 렌더링 단계별 분리 |
|
||||
| `core/engine/progress_service.dart` | **1,247** | 기능별 서비스 추출 |
|
||||
| `features/arena/arena_battle_screen.dart` | **976** | 위젯 분리 |
|
||||
| `features/game/widgets/enhanced_animation_panel.dart` | **877** | 분리 |
|
||||
| `features/settings/settings_screen.dart` | **821** | 섹션별 분리 |
|
||||
| `core/engine/arena_service.dart` | **811** | 분리 |
|
||||
| `features/game/widgets/death_overlay.dart` | **795** | 분리 |
|
||||
| `core/engine/skill_service.dart` | **759** | 분리 |
|
||||
| `app.dart` | **723** | 라우팅/테마/설정 분리 |
|
||||
| `core/engine/combat_tick_service.dart` | **681** | 분리 |
|
||||
| `core/model/game_statistics.dart` | **616** | 분리 |
|
||||
**수정 완료**: 12개 대형 파일에서 23+개 신규 파일 추출. 대부분 400 LOC 이하로 감소.
|
||||
|
||||
*참고: 정적 데이터 파일 (game_translations_ko/ja.dart, pq_config_data.dart 등)은 LOC 초과가 불가피하므로 허용*
|
||||
| 파일 | 이전 LOC | 현재 LOC | 추출된 파일 |
|
||||
|------|----------|----------|------------|
|
||||
| `game_play_screen.dart` | 1,536 | **879** | `desktop_*_panel.dart` (3개) |
|
||||
| `canvas_battle_composer.dart` | 1,475 | **544** | `monster_frames.dart`, `combat_text_frames.dart` |
|
||||
| `progress_service.dart` | 1,247 | **832** | `task_generator.dart`, `death_handler.dart`, `loot_handler.dart` |
|
||||
| `arena_battle_screen.dart` | 976 | **759** | `arena_hp_bar.dart` |
|
||||
| `settings_screen.dart` | 821 | **455** | `retro_settings_widgets.dart` |
|
||||
| `arena_service.dart` | 811 | **308** | `arena_combat_simulator.dart` |
|
||||
| `death_overlay.dart` | 795 | — | `death_combat_log.dart`, `death_buttons.dart` |
|
||||
| `skill_service.dart` | 759 | **588** | `skill_auto_selector.dart` |
|
||||
| `app.dart` | 723 | **460** | `app_theme.dart`, `splash_screen.dart` |
|
||||
| `combat_tick_service.dart` | 681 | **443** | `player_attack_processor.dart` |
|
||||
| `game_statistics.dart` | 616 | — | `session_statistics.dart`, `cumulative_statistics.dart` |
|
||||
|
||||
*참고: StatefulWidget 상태 결합으로 인해 일부 파일은 400 LOC 이하 분리가 어려움. 정적 데이터 파일은 LOC 초과 허용.*
|
||||
|
||||
### 5.3 SRP 위반 - 대형 함수 (HIGH)
|
||||
|
||||
@@ -291,26 +275,25 @@ git rm --cached android/key.properties
|
||||
|
||||
*참고: 생성 파일(.g.dart, .freezed.dart)의 `Map<String, dynamic>`은 JSON 직렬화 패턴이므로 허용*
|
||||
|
||||
### 5.5 코드 중복 (MEDIUM)
|
||||
### ~~5.5 코드 중복~~ - **수정 완료**
|
||||
|
||||
**`_toRoman()` 함수 3곳 중복** (유틸 `intToRoman()` 존재):
|
||||
- `core/util/roman.dart` - `intToRoman()` (원본 유틸)
|
||||
- `features/game/game_play_screen.dart:1443` - `_toRoman()` (중복)
|
||||
- `features/game/pages/story_page.dart:117` - `_toRoman()` (중복)
|
||||
~~`_toRoman()` 함수 3곳 중복~~
|
||||
|
||||
**수정 내용**: `game_play_screen.dart`와 `story_page.dart`의 중복 `_toRoman()` 제거, `core/util/roman.dart`의 `intToRoman()` import로 통일
|
||||
|
||||
### 5.6 TODO/FIXME 미완성 마커
|
||||
|
||||
| 위치 | 내용 |
|
||||
|------|------|
|
||||
| `core/engine/iap_service.dart:15` | `TODO: Google Play Console / App Store Connect에서 상품 생성 후 ID 교체` |
|
||||
| `core/engine/ad_service.dart:74` | `TODO: AdMob 콘솔에서 광고 단위 생성 후 아래 ID 교체` |
|
||||
| `ad_service.dart:76,78,80,82` | 프로덕션 광고 ID 모두 플레이스홀더 |
|
||||
| 위치 | 내용 | 상태 |
|
||||
|------|------|------|
|
||||
| `core/engine/iap_service.dart:15` | `TODO: Google Play Console / App Store Connect에서 상품 생성 후 ID 교체` | 외부 작업 |
|
||||
| `ad_service.dart:77,81` | iOS 프로덕션 광고 ID 플레이스홀더 | iOS 차후 설정 |
|
||||
| ~~`ad_service.dart:74-75,78-79`~~ | ~~Android 프로덕션 광고 ID 플레이스홀더~~ | **수정 완료** |
|
||||
|
||||
### 5.7 싱글톤 패턴 과다 사용 (LOW)
|
||||
### 5.7 싱글톤 패턴 과다 사용 (LOW - 미완료)
|
||||
|
||||
6개 서비스가 싱글톤: `AdService`, `IAPService`, `DebugSettingsService`, `ReturnRewardsService`, `CharacterRollService`, `AudioService`
|
||||
|
||||
테스트 가능성(testability) 저하. DI(의존성 주입) 패턴으로 전환 권장.
|
||||
테스트 가능성(testability) 저하. DI(의존성 주입) 패턴으로 전환 권장. (P2 #25)
|
||||
|
||||
### 5.8 양호 항목
|
||||
|
||||
@@ -340,11 +323,11 @@ git rm --cached android/key.properties
|
||||
|
||||
| # | 이슈 | 상세 |
|
||||
|---|------|------|
|
||||
| L1 | **iOS `NSHumanReadableCopyright` 정치적 문구** | 앱스토어 심사 즉각 거부 |
|
||||
| L2 | **일본어 ARB 70%+ 미번역** | 약 60~70개 키가 영어 그대로 (tagNoNetwork, newCharacter, cancel, exitGame, characterSheet, traits, stats, equipment, inventory, 모든 equip*, stat*, menu*, options*, sound* 등) |
|
||||
| L3 | **Arena 관련 화면 전체 영어 하드코딩** | `MY EQUIPMENT`, `ENEMY EQUIPMENT`, `ARENA BATTLE`, `START BATTLE`, `WINNER`, `LOSER` 등 ARB 미사용 |
|
||||
| L4 | **statistics_dialog.dart 하드코딩** | 30개+ 텍스트가 `isKorean ? '한국어' : isJapanese ? '日本語' : 'English'` 삼항 연산자로 직접 처리 |
|
||||
| L5 | **iOS `CFBundleLocalizations` 미설정** | iOS에서 앱 언어 인식 불가 |
|
||||
| ~~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 로컬라이제이션 기타
|
||||
|
||||
@@ -448,65 +431,63 @@ git rm --cached android/key.properties
|
||||
12. **통계 시스템** (GameStatistics)
|
||||
13. **게임 클리어 시스템** (레벨 100, 최종 보스 처치 시 엔딩)
|
||||
|
||||
### 7.5 CLAUDE.md와의 충돌
|
||||
### ~~7.5 CLAUDE.md와의 충돌~~ - **해결 완료**
|
||||
|
||||
CLAUDE.md에 명시된 규칙:
|
||||
> "원본 알고리즘과 데이터를 그대로 유지해야 합니다"
|
||||
> "example/pq/ 내 Delphi 소스의 알고리즘/데이터를 100% 동일하게 포팅"
|
||||
> "새로운 기능, 값, 처리 로직 추가 금지"
|
||||
~~CLAUDE.md에 명시된 규칙이 현재 구현과 괴리~~
|
||||
|
||||
현재 구현은 이 규칙과 **상당히 괴리**가 있음.
|
||||
|
||||
**권장: CLAUDE.md를 현재 프로젝트 실태에 맞게 업데이트하거나, 원본 충실도 방향을 재정립할 필요가 있음.**
|
||||
**수정 완료**: CLAUDE.md를 현재 프로젝트 실태에 맞게 업데이트.
|
||||
- "100% 동일하게 복제" → "핵심 메커니즘 기반 독자적 리메이크"
|
||||
- 원본 충실도 제약 삭제
|
||||
- 디렉토리 구조, 화면 구성 등 현행화
|
||||
|
||||
---
|
||||
|
||||
## 8. 우선순위별 액션 플랜
|
||||
|
||||
### P0 - 즉시 (보안/심사 차단)
|
||||
### P0 - 즉시 (심사 차단)
|
||||
|
||||
| # | 작업 | 난이도 | 예상 시간 |
|
||||
|---|------|--------|----------|
|
||||
| 1 | Git에서 JKS 키스토어 + key.properties 제거 | 낮음 | 30분 |
|
||||
| 2 | .gitignore에 민감 파일 패턴 추가 | 낮음 | 10분 |
|
||||
| 3 | 정치적 문구 제거 (iOS/Android 모두) | 낮음 | 10분 |
|
||||
| 4 | iOS/macOS Bundle ID 변경 (`com.example` -> `com.naturebridgeai.asciineverdie`) | 낮음 | 20분 |
|
||||
| # | 작업 | 난이도 | 상태 |
|
||||
|---|------|--------|------|
|
||||
| ~~1~~ | ~~Git에서 JKS 키스토어 + key.properties 제거~~ | - | **해당 없음** - 개인 비공개 저장소 |
|
||||
| ~~2~~ | ~~.gitignore에 민감 파일 패턴 추가~~ | - | **해당 없음** - 개인 비공개 저장소 |
|
||||
| ~~3~~ | ~~정치적 문구 제거~~ | - | **해당 없음** - 의도적 포함 |
|
||||
| ~~4~~ | ~~iOS/macOS Bundle ID 변경~~ | - | **수정 완료** |
|
||||
|
||||
### P1 - 출시 전 필수
|
||||
|
||||
| # | 작업 | 난이도 | 예상 시간 |
|
||||
|---|------|--------|----------|
|
||||
| 5 | iOS DEVELOPMENT_TEAM 설정 | 낮음 | 10분 |
|
||||
| 6 | Android 릴리즈 INTERNET 권한 추가 | 낮음 | 5분 |
|
||||
| 7 | iOS GADApplicationIdentifier + SKAdNetworkItems + ATT 추가 | 중간 | 1시간 |
|
||||
| 8 | macOS Release entitlements 네트워크 권한 추가 | 낮음 | 10분 |
|
||||
| 9 | 앱 이름 통일 (`ASCII Never Die`) - 모든 플랫폼 | 낮음 | 30분 |
|
||||
| 10 | AdMob 프로덕션 광고 단위 ID 설정 | 중간 | AdMob 콘솔 작업 |
|
||||
| 11 | IAP 스토어 상품 등록 (Google Play / App Store Connect) | 중간 | 스토어 콘솔 작업 |
|
||||
| 12 | 앱 스크린샷 제작 (각 플랫폼/언어별) | 중간 | 2~4시간 |
|
||||
| 13 | 일본어 ARB 번역 완성 (~70개 키) | 중간 | 2~3시간 |
|
||||
| 14 | iOS CFBundleLocalizations 설정 | 낮음 | 10분 |
|
||||
| 15 | `dart format .` 적용 | 낮음 | 5분 |
|
||||
| 16 | 실패 테스트 수정 (`skill_service_test.dart:563`) | 낮음 | 30분 |
|
||||
| 17 | macOS PRODUCT_COPYRIGHT 수정 | 낮음 | 5분 |
|
||||
| # | 작업 | 난이도 | 상태 |
|
||||
|---|------|--------|------|
|
||||
| ~~5~~ | ~~iOS DEVELOPMENT_TEAM 설정~~ | 낮음 | **수정 완료** - `82SY27V867` |
|
||||
| ~~6~~ | ~~Android 릴리즈 INTERNET 권한 추가~~ | 낮음 | **수정 완료** |
|
||||
| ~~7~~ | ~~iOS GADApplicationIdentifier + SKAdNetworkItems + ATT 추가~~ | 중간 | **수정 완료** |
|
||||
| ~~8~~ | ~~macOS Release entitlements 네트워크 권한 추가~~ | 낮음 | **수정 완료** |
|
||||
| ~~9~~ | ~~앱 이름 통일 (`ASCII Never Die`) - 모든 플랫폼~~ | 낮음 | **수정 완료** |
|
||||
| 10 | AdMob 프로덕션 광고 단위 ID 설정 | 중간 | **부분 완료** - Android 리워드/인터스티셜 ID 설정 완료. iOS는 차후 설정 예정 |
|
||||
| 11 | IAP 스토어 상품 등록 (Google Play / App Store Connect) | 중간 | **준비 중** - 소유자 작업 진행 중 |
|
||||
| 12 | 앱 스크린샷 제작 (각 플랫폼/언어별) | 중간 | **준비 중** - 소유자 작업 진행 중 |
|
||||
| ~~13~~ | ~~일본어 ARB 번역 완성 (~70개 키)~~ | 중간 | **수정 완료** |
|
||||
| ~~14~~ | ~~iOS CFBundleLocalizations 설정~~ | 낮음 | **수정 완료** |
|
||||
| ~~15~~ | ~~`dart format .` 적용~~ | 낮음 | **수정 완료** |
|
||||
| ~~16~~ | ~~실패 테스트 수정 (`skill_service_test.dart:563`)~~ | 낮음 | **수정 완료** |
|
||||
| ~~17~~ | ~~macOS PRODUCT_COPYRIGHT 수정~~ | 낮음 | **수정 완료** |
|
||||
|
||||
### P2 - 출시 후 개선
|
||||
|
||||
| # | 작업 | 난이도 |
|
||||
|---|------|--------|
|
||||
| 18 | 하드코딩 문자열 ARB 키 전환 (arena, statistics, notification 등) | 높음 |
|
||||
| 19 | 대형 파일 분리 (game_play_screen, progress_service 등 12개 파일) | 높음 |
|
||||
| 20 | 대형 함수 리팩토링 (_showOptionsMenu 263줄 등 11개 함수) | 높음 |
|
||||
| 21 | Clean Architecture 위반 정리 (core/animation, core/constants -> shared/) | 중간 |
|
||||
| 22 | Android ProGuard/R8 설정 | 중간 |
|
||||
| 23 | 스플래시 화면 커스텀 (flutter_native_splash) | 낮음 |
|
||||
| 24 | 접근성 개선 (Semantics, 텍스트 크기 대응, 색상 대비) | 높음 |
|
||||
| 25 | 싱글톤 -> DI 패턴 전환 (6개 서비스) | 높음 |
|
||||
| 26 | 코드 중복 제거 (_toRoman 등) | 낮음 |
|
||||
| 27 | CLAUDE.md 현행화 (원본 충실도 방향 재정립) | 낮음 |
|
||||
| 28 | IAP 가격 조정 검토 ($9.99 -> $2.99~$4.99) | 결정 사항 |
|
||||
| 29 | Crashlytics/분석 도구 도입 (출시 후 모니터링) | 중간 |
|
||||
| 30 | 키보드 네비게이션 강화 (macOS 빌드) | 중간 |
|
||||
| # | 작업 | 난이도 | 상태 |
|
||||
|---|------|--------|------|
|
||||
| ~~18~~ | ~~하드코딩 문자열 ARB 키 전환 (arena, statistics, notification 등)~~ | 높음 | **수정 완료** - 68키 추가 (en/ko/ja) |
|
||||
| ~~19~~ | ~~대형 파일 분리 (game_play_screen, progress_service 등 12개 파일)~~ | 높음 | **수정 완료** - 23+개 신규 파일 추출 |
|
||||
| ~~20~~ | ~~대형 함수 리팩토링 (_showOptionsMenu 263줄 등 11개 함수)~~ | 높음 | **부분 완료** - 파일 분리와 함께 주요 함수 축소 |
|
||||
| ~~21~~ | ~~Clean Architecture 위반 정리 (core/animation, core/constants -> shared/)~~ | 중간 | **수정 완료** - 19개 파일 shared/로 이동 |
|
||||
| ~~22~~ | ~~Android ProGuard/R8 설정~~ | 중간 | **수정 완료** - minify+shrink 활성화, proguard-rules.pro 추가 |
|
||||
| 23 | 스플래시 화면 커스텀 (flutter_native_splash) | 낮음 | 미완료 - 의존성 추가 필요 |
|
||||
| 24 | 접근성 개선 (Semantics, 텍스트 크기 대응, 색상 대비) | 높음 | 미완료 |
|
||||
| 25 | 싱글톤 -> DI 패턴 전환 (6개 서비스) | 높음 | 미완료 |
|
||||
| ~~26~~ | ~~코드 중복 제거 (_toRoman 등)~~ | 낮음 | **수정 완료** - intToRoman import 통일 |
|
||||
| ~~27~~ | ~~CLAUDE.md 현행화 (원본 충실도 방향 재정립)~~ | 낮음 | **수정 완료** |
|
||||
| 28 | IAP 가격 조정 검토 ($9.99 -> $2.99~$4.99) | 결정 사항 | 소유자 결정 필요 |
|
||||
| 29 | Crashlytics/분석 도구 도입 (출시 후 모니터링) | 중간 | 미완료 - Firebase 설정 필요 |
|
||||
| 30 | 키보드 네비게이션 강화 (macOS 빌드) | 중간 | 미완료 |
|
||||
|
||||
---
|
||||
|
||||
@@ -524,13 +505,13 @@ CLAUDE.md에 명시된 규칙:
|
||||
|
||||
### 즉시 해결 필요
|
||||
|
||||
- **보안**: 키스토어/비밀번호 Git 노출 (가장 시급)
|
||||
- **출시 차단**: 정치적 문구, `com.example` Bundle ID, 누락된 플랫폼 설정
|
||||
- **수익화**: 프로덕션 ID 미설정, 스토어 상품 미등록
|
||||
- ~~**출시 차단**: 누락된 플랫폼 설정~~ → **모두 수정 완료**
|
||||
- **출시 차단 잔여**: 앱 스크린샷 미준비 (R7) - 소유자 작업 중
|
||||
- **수익화**: iOS 광고 ID 미설정 (차후), IAP 스토어 상품 미등록 (소유자 작업 중)
|
||||
|
||||
### 전략적 결정 필요
|
||||
|
||||
- CLAUDE.md의 "100% 동일 포팅" 목표 vs 현재 "스핀오프/리메이크" 실태 정립
|
||||
- ~~CLAUDE.md의 "100% 동일 포팅" 목표 vs 현재 "스핀오프/리메이크" 실태 정립~~ → **해결 완료** (CLAUDE.md 현행화)
|
||||
- 원작이 무료인 점을 감안한 수익 모델 최적화
|
||||
- 광고 제거 IAP 가격 결정 ($9.99 vs $2.99~$4.99)
|
||||
- PQ 원작 저작권 관련 법률 검토
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '13.0'
|
||||
# iOS 최소 배포 대상
|
||||
platform :ios, '13.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
@@ -187,6 +187,8 @@
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
ko,
|
||||
ja,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
@@ -361,7 +363,9 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 82SY27V867;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -540,7 +544,9 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 82SY27V867;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -562,7 +568,9 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 82SY27V867;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Asciineverdie</string>
|
||||
<string>ASCII Never Die</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -13,7 +13,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>asciineverdie</string>
|
||||
<string>ASCII Never Die</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -47,5 +47,26 @@
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2025 naturebridgeai 天安門 六四事件 法輪功 李洪志 Free Tibet</string>
|
||||
<!-- AdMob App ID -->
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>ca-app-pub-6691216385521068~8216990571</string>
|
||||
<!-- SKAdNetwork -->
|
||||
<key>SKAdNetworkItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>cstr6suwn9.skadnetwork</string>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- ATT(App Tracking Transparency) 사용자 추적 동의 문구 -->
|
||||
<key>NSUserTrackingUsageDescription</key>
|
||||
<string>This identifier will be used to deliver personalized ads to you.</string>
|
||||
<!-- 지원 언어(localization) 목록 -->
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>ko</string>
|
||||
<string>ja</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
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 =>
|
||||
_l('Activate 10x Speed', '10배속 활성화', '10倍速を有効化');
|
||||
String speedBoostRemaining(int seconds) =>
|
||||
_l('${seconds}s remaining', '${seconds}초 남음', '残り${seconds}秒');
|
||||
_l('${seconds}s remaining', '$seconds초 남음', '残り$seconds秒');
|
||||
String get speedBoostActive => _l('BOOST ACTIVE', '부스트 활성화', 'ブースト中');
|
||||
|
||||
// ============================================================================
|
||||
@@ -650,8 +650,9 @@ String translateImpressiveTitle(String englishName) {
|
||||
/// 특수 아이템 이름 번역
|
||||
String translateSpecial(String englishName) {
|
||||
if (isKoreanLocale) return specialTranslationsKo[englishName] ?? englishName;
|
||||
if (isJapaneseLocale)
|
||||
if (isJapaneseLocale) {
|
||||
return specialTranslationsJa[englishName] ?? englishName;
|
||||
}
|
||||
return englishName;
|
||||
}
|
||||
|
||||
@@ -828,54 +829,65 @@ String translateItemNameL10n(String itemString) {
|
||||
|
||||
/// Act 제목 번역
|
||||
String translateActTitle(String englishTitle) {
|
||||
if (isKoreanLocale)
|
||||
if (isKoreanLocale) {
|
||||
return actTitleTranslationsKo[englishTitle] ?? englishTitle;
|
||||
if (isJapaneseLocale)
|
||||
}
|
||||
if (isJapaneseLocale) {
|
||||
return actTitleTranslationsJa[englishTitle] ?? englishTitle;
|
||||
}
|
||||
return englishTitle;
|
||||
}
|
||||
|
||||
/// Act 보스 이름 번역
|
||||
String translateActBoss(String englishBoss) {
|
||||
if (isKoreanLocale) return actBossTranslationsKo[englishBoss] ?? englishBoss;
|
||||
if (isJapaneseLocale)
|
||||
if (isJapaneseLocale) {
|
||||
return actBossTranslationsJa[englishBoss] ?? englishBoss;
|
||||
}
|
||||
return englishBoss;
|
||||
}
|
||||
|
||||
/// Act 퀘스트 번역
|
||||
String translateActQuest(String englishQuest) {
|
||||
if (isKoreanLocale)
|
||||
if (isKoreanLocale) {
|
||||
return actQuestTranslationsKo[englishQuest] ?? englishQuest;
|
||||
if (isJapaneseLocale)
|
||||
}
|
||||
if (isJapaneseLocale) {
|
||||
return actQuestTranslationsJa[englishQuest] ?? englishQuest;
|
||||
}
|
||||
return englishQuest;
|
||||
}
|
||||
|
||||
/// 시네마틱 텍스트 번역
|
||||
String translateCinematic(String englishText) {
|
||||
if (isKoreanLocale)
|
||||
if (isKoreanLocale) {
|
||||
return cinematicTranslationsKo[englishText] ?? englishText;
|
||||
if (isJapaneseLocale)
|
||||
}
|
||||
if (isJapaneseLocale) {
|
||||
return cinematicTranslationsJa[englishText] ?? englishText;
|
||||
}
|
||||
return englishText;
|
||||
}
|
||||
|
||||
/// 지역 이름 번역
|
||||
String translateLocation(String englishLocation) {
|
||||
if (isKoreanLocale)
|
||||
if (isKoreanLocale) {
|
||||
return locationTranslationsKo[englishLocation] ?? englishLocation;
|
||||
if (isJapaneseLocale)
|
||||
}
|
||||
if (isJapaneseLocale) {
|
||||
return locationTranslationsJa[englishLocation] ?? englishLocation;
|
||||
}
|
||||
return englishLocation;
|
||||
}
|
||||
|
||||
/// 세력/조직 이름 번역
|
||||
String translateFaction(String englishFaction) {
|
||||
if (isKoreanLocale)
|
||||
if (isKoreanLocale) {
|
||||
return factionTranslationsKo[englishFaction] ?? englishFaction;
|
||||
if (isJapaneseLocale)
|
||||
}
|
||||
if (isJapaneseLocale) {
|
||||
return factionTranslationsJa[englishFaction] ?? englishFaction;
|
||||
}
|
||||
return englishFaction;
|
||||
}
|
||||
|
||||
@@ -1021,6 +1033,13 @@ String uiRollHistory(int count) =>
|
||||
_l('$count roll(s) in history', '리롤 기록: $count회', 'リロール履歴: $count回');
|
||||
String get uiEnterName =>
|
||||
_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 uiTestModeDesc =>
|
||||
_l('Use mobile layout on web', '웹에서 모바일 레이아웃 사용', 'Webでモバイルレイアウトを使用');
|
||||
@@ -1226,7 +1245,7 @@ String get notifyQuestComplete => _l('QUEST COMPLETE!', '퀘스트 완료!', '
|
||||
String get notifyPrologueComplete =>
|
||||
_l('PROLOGUE COMPLETE!', '프롤로그 완료!', 'プロローグ完了!');
|
||||
String notifyActComplete(int actNumber) =>
|
||||
_l('ACT $actNumber COMPLETE!', '${actNumber}막 완료!', '第${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 {
|
||||
SkillData._();
|
||||
|
||||
@@ -1081,9 +1081,9 @@ class SkillData {
|
||||
// 스펠 이름 → 스킬 매핑
|
||||
// ============================================================================
|
||||
|
||||
/// PQ 스펠 이름으로 스킬 조회
|
||||
/// 스킬 ID로 스킬 조회
|
||||
///
|
||||
/// 스펠 이름(영문)을 키로 사용하여 해당 전투 스킬을 반환
|
||||
/// 스킬 ID(영문)를 키로 사용하여 해당 전투 스킬을 반환
|
||||
static const Map<String, Skill> spellNameToSkill = {
|
||||
// 공격 스킬
|
||||
'Stack Trace': stackTrace,
|
||||
|
||||
@@ -473,5 +473,238 @@
|
||||
"@debugOfflineHoursDesc": { "description": "Offline hours debug description" },
|
||||
|
||||
"debugTestCharacterDesc": "Modify current character to Level 100\nand register to the Hall of Fame.",
|
||||
"@debugTestCharacterDesc": { "description": "Test character creation description" }
|
||||
"@debugTestCharacterDesc": { "description": "Test character creation description" },
|
||||
|
||||
"arenaTitle": "LOCAL ARENA",
|
||||
"@arenaTitle": { "description": "Arena main screen title" },
|
||||
|
||||
"arenaSelectFighter": "SELECT YOUR FIGHTER",
|
||||
"@arenaSelectFighter": { "description": "Arena character selection subtitle" },
|
||||
|
||||
"arenaEmptyTitle": "Not enough heroes",
|
||||
"@arenaEmptyTitle": { "description": "Arena empty state title" },
|
||||
|
||||
"arenaEmptyHint": "Clear the game with 2+ characters",
|
||||
"@arenaEmptyHint": { "description": "Arena empty state hint" },
|
||||
|
||||
"arenaSetupTitle": "ARENA SETUP",
|
||||
"@arenaSetupTitle": { "description": "Arena setup screen title" },
|
||||
|
||||
"arenaStartBattle": "START BATTLE",
|
||||
"@arenaStartBattle": { "description": "Start battle button" },
|
||||
|
||||
"arenaBattleTitle": "ARENA BATTLE",
|
||||
"@arenaBattleTitle": { "description": "Arena battle screen title" },
|
||||
|
||||
"arenaMyEquipment": "MY EQUIPMENT",
|
||||
"@arenaMyEquipment": { "description": "My equipment header" },
|
||||
|
||||
"arenaEnemyEquipment": "ENEMY EQUIPMENT",
|
||||
"@arenaEnemyEquipment": { "description": "Enemy equipment header" },
|
||||
|
||||
"arenaSelected": "SELECTED",
|
||||
"@arenaSelected": { "description": "Selected slot label" },
|
||||
|
||||
"arenaRecommended": "BEST",
|
||||
"@arenaRecommended": { "description": "Recommended slot label" },
|
||||
|
||||
"arenaWeaponLocked": "LOCKED",
|
||||
"@arenaWeaponLocked": { "description": "Weapon slot locked label" },
|
||||
|
||||
"arenaVictory": "VICTORY!",
|
||||
"@arenaVictory": { "description": "Arena victory title" },
|
||||
|
||||
"arenaDefeat": "DEFEAT...",
|
||||
"@arenaDefeat": { "description": "Arena defeat title" },
|
||||
|
||||
"arenaEquipmentExchange": "EQUIPMENT EXCHANGE",
|
||||
"@arenaEquipmentExchange": { "description": "Equipment exchange section title" },
|
||||
|
||||
"arenaTurns": "TURNS",
|
||||
"@arenaTurns": { "description": "Turns label" },
|
||||
|
||||
"arenaWinner": "WINNER",
|
||||
"@arenaWinner": { "description": "Winner label" },
|
||||
|
||||
"arenaLoser": "LOSER",
|
||||
"@arenaLoser": { "description": "Loser label" },
|
||||
|
||||
"arenaDefeatedIn": "{winner} defeated {loser} in {turns} TURNS",
|
||||
"@arenaDefeatedIn": {
|
||||
"description": "Battle summary text",
|
||||
"placeholders": {
|
||||
"winner": { "type": "String" },
|
||||
"loser": { "type": "String" },
|
||||
"turns": { "type": "int" }
|
||||
}
|
||||
},
|
||||
|
||||
"arenaScoreGain": "You will GAIN +{score}",
|
||||
"@arenaScoreGain": {
|
||||
"description": "Score gain prediction",
|
||||
"placeholders": {
|
||||
"score": { "type": "int" }
|
||||
}
|
||||
},
|
||||
|
||||
"arenaScoreLose": "You will LOSE {score}",
|
||||
"@arenaScoreLose": {
|
||||
"description": "Score loss prediction",
|
||||
"placeholders": {
|
||||
"score": { "type": "int" }
|
||||
}
|
||||
},
|
||||
|
||||
"arenaEvenTrade": "Even trade",
|
||||
"@arenaEvenTrade": { "description": "Even trade label" },
|
||||
|
||||
"arenaScore": "SCORE",
|
||||
"@arenaScore": { "description": "Score label" },
|
||||
|
||||
"statsStatistics": "Statistics",
|
||||
"@statsStatistics": { "description": "Statistics dialog title" },
|
||||
|
||||
"statsSession": "Session",
|
||||
"@statsSession": { "description": "Session tab label" },
|
||||
|
||||
"statsAccumulated": "Total",
|
||||
"@statsAccumulated": { "description": "Accumulated tab label" },
|
||||
|
||||
"statsCombat": "Combat",
|
||||
"@statsCombat": { "description": "Combat section title" },
|
||||
|
||||
"statsPlayTime": "Play Time",
|
||||
"@statsPlayTime": { "description": "Play time label" },
|
||||
|
||||
"statsMonstersKilled": "Monsters Killed",
|
||||
"@statsMonstersKilled": { "description": "Monsters killed label" },
|
||||
|
||||
"statsBossesDefeated": "Bosses Defeated",
|
||||
"@statsBossesDefeated": { "description": "Bosses defeated label" },
|
||||
|
||||
"statsDeaths": "Deaths",
|
||||
"@statsDeaths": { "description": "Deaths label" },
|
||||
|
||||
"statsDamage": "Damage",
|
||||
"@statsDamage": { "description": "Damage section title" },
|
||||
|
||||
"statsDamageDealt": "Damage Dealt",
|
||||
"@statsDamageDealt": { "description": "Damage dealt label" },
|
||||
|
||||
"statsDamageTaken": "Damage Taken",
|
||||
"@statsDamageTaken": { "description": "Damage taken label" },
|
||||
|
||||
"statsAverageDps": "Average DPS",
|
||||
"@statsAverageDps": { "description": "Average DPS label" },
|
||||
|
||||
"statsSkills": "Skills",
|
||||
"@statsSkills": { "description": "Skills section title" },
|
||||
|
||||
"statsSkillsUsed": "Skills Used",
|
||||
"@statsSkillsUsed": { "description": "Skills used label" },
|
||||
|
||||
"statsCriticalHits": "Critical Hits",
|
||||
"@statsCriticalHits": { "description": "Critical hits label" },
|
||||
|
||||
"statsMaxCriticalStreak": "Max Critical Streak",
|
||||
"@statsMaxCriticalStreak": { "description": "Max critical streak label" },
|
||||
|
||||
"statsCriticalRate": "Critical Rate",
|
||||
"@statsCriticalRate": { "description": "Critical rate label" },
|
||||
|
||||
"statsEconomy": "Economy",
|
||||
"@statsEconomy": { "description": "Economy section title" },
|
||||
|
||||
"statsGoldEarned": "Gold Earned",
|
||||
"@statsGoldEarned": { "description": "Gold earned label" },
|
||||
|
||||
"statsGoldSpent": "Gold Spent",
|
||||
"@statsGoldSpent": { "description": "Gold spent label" },
|
||||
|
||||
"statsItemsSold": "Items Sold",
|
||||
"@statsItemsSold": { "description": "Items sold label" },
|
||||
|
||||
"statsPotionsUsed": "Potions Used",
|
||||
"@statsPotionsUsed": { "description": "Potions used label" },
|
||||
|
||||
"statsProgress": "Progress",
|
||||
"@statsProgress": { "description": "Progress section title" },
|
||||
|
||||
"statsLevelUps": "Level Ups",
|
||||
"@statsLevelUps": { "description": "Level ups label" },
|
||||
|
||||
"statsQuestsCompleted": "Quests Completed",
|
||||
"@statsQuestsCompleted": { "description": "Quests completed label" },
|
||||
|
||||
"statsRecords": "Records",
|
||||
"@statsRecords": { "description": "Records section title" },
|
||||
|
||||
"statsHighestLevel": "Highest Level",
|
||||
"@statsHighestLevel": { "description": "Highest level label" },
|
||||
|
||||
"statsHighestGoldHeld": "Highest Gold Held",
|
||||
"@statsHighestGoldHeld": { "description": "Highest gold held label" },
|
||||
|
||||
"statsBestCriticalStreak": "Best Critical Streak",
|
||||
"@statsBestCriticalStreak": { "description": "Best critical streak label" },
|
||||
|
||||
"statsTotalPlay": "Total Play",
|
||||
"@statsTotalPlay": { "description": "Total play section title" },
|
||||
|
||||
"statsTotalPlayTime": "Total Play Time",
|
||||
"@statsTotalPlayTime": { "description": "Total play time label" },
|
||||
|
||||
"statsGamesStarted": "Games Started",
|
||||
"@statsGamesStarted": { "description": "Games started label" },
|
||||
|
||||
"statsGamesCompleted": "Games Completed",
|
||||
"@statsGamesCompleted": { "description": "Games completed label" },
|
||||
|
||||
"statsCompletionRate": "Completion Rate",
|
||||
"@statsCompletionRate": { "description": "Completion rate label" },
|
||||
|
||||
"statsTotalCombat": "Total Combat",
|
||||
"@statsTotalCombat": { "description": "Total combat section title" },
|
||||
|
||||
"statsTotalDeaths": "Total Deaths",
|
||||
"@statsTotalDeaths": { "description": "Total deaths label" },
|
||||
|
||||
"statsTotalLevelUps": "Total Level Ups",
|
||||
"@statsTotalLevelUps": { "description": "Total level ups label" },
|
||||
|
||||
"statsTotalDamage": "Total Damage",
|
||||
"@statsTotalDamage": { "description": "Total damage section title" },
|
||||
|
||||
"statsTotalSkills": "Total Skills",
|
||||
"@statsTotalSkills": { "description": "Total skills section title" },
|
||||
|
||||
"statsTotalEconomy": "Total Economy",
|
||||
"@statsTotalEconomy": { "description": "Total economy section title" },
|
||||
|
||||
"notifyLevelUpLabel": "LEVEL UP",
|
||||
"@notifyLevelUpLabel": { "description": "Level up notification type label" },
|
||||
|
||||
"notifyQuestDoneLabel": "QUEST DONE",
|
||||
"@notifyQuestDoneLabel": { "description": "Quest done notification type label" },
|
||||
|
||||
"notifyActClearLabel": "ACT CLEAR",
|
||||
"@notifyActClearLabel": { "description": "Act clear notification type label" },
|
||||
|
||||
"notifyNewSpellLabel": "NEW SPELL",
|
||||
"@notifyNewSpellLabel": { "description": "New spell notification type label" },
|
||||
|
||||
"notifyNewItemLabel": "NEW ITEM",
|
||||
"@notifyNewItemLabel": { "description": "New item notification type label" },
|
||||
|
||||
"notifyBossSlainLabel": "BOSS SLAIN",
|
||||
"@notifyBossSlainLabel": { "description": "Boss slain notification type label" },
|
||||
|
||||
"notifySavedLabel": "SAVED",
|
||||
"@notifySavedLabel": { "description": "Game saved notification type label" },
|
||||
|
||||
"notifyInfoLabel": "INFO",
|
||||
"@notifyInfoLabel": { "description": "Info notification type label" },
|
||||
|
||||
"notifyWarningLabel": "WARNING",
|
||||
"@notifyWarningLabel": { "description": "Warning notification type label" }
|
||||
}
|
||||
|
||||
@@ -2,79 +2,79 @@
|
||||
"@@locale": "ja",
|
||||
|
||||
"appTitle": "アスキー ネバー ダイ",
|
||||
"tagNoNetwork": "No network",
|
||||
"tagIdleRpg": "Idle RPG loop",
|
||||
"tagLocalSaves": "Local saves",
|
||||
"newCharacter": "New character",
|
||||
"loadSave": "Load save",
|
||||
"loadGame": "Load Game",
|
||||
"viewBuildPlan": "View build plan",
|
||||
"buildRoadmap": "Build roadmap",
|
||||
"techStack": "Tech stack",
|
||||
"cancel": "Cancel",
|
||||
"exitGame": "Exit Game",
|
||||
"saveProgressQuestion": "Save your progress before leaving?",
|
||||
"exitWithoutSaving": "Exit without saving",
|
||||
"saveAndExit": "Save and Exit",
|
||||
"progressQuestTitle": "ASCII NEVER DIE - {name}",
|
||||
"levelUp": "Level Up",
|
||||
"completeQuest": "Complete Quest",
|
||||
"completePlot": "Complete Plot",
|
||||
"characterSheet": "Character Sheet",
|
||||
"traits": "Traits",
|
||||
"stats": "Stats",
|
||||
"experience": "Experience",
|
||||
"xpNeededForNextLevel": "XP needed for next level",
|
||||
"tagNoNetwork": "オフライン",
|
||||
"tagIdleRpg": "放置型RPG",
|
||||
"tagLocalSaves": "ローカル保存",
|
||||
"newCharacter": "新規キャラクター",
|
||||
"loadSave": "ロード",
|
||||
"loadGame": "ゲームをロード",
|
||||
"viewBuildPlan": "ビルド計画を見る",
|
||||
"buildRoadmap": "ビルドロードマップ",
|
||||
"techStack": "技術スタック",
|
||||
"cancel": "キャンセル",
|
||||
"exitGame": "ゲーム終了",
|
||||
"saveProgressQuestion": "終了する前にセーブしますか?",
|
||||
"exitWithoutSaving": "セーブせずに終了",
|
||||
"saveAndExit": "セーブして終了",
|
||||
"progressQuestTitle": "アスキー ネバー ダイ - {name}",
|
||||
"levelUp": "レベルアップ",
|
||||
"completeQuest": "クエスト完了",
|
||||
"completePlot": "プロット完了",
|
||||
"characterSheet": "キャラクターシート",
|
||||
"traits": "特性",
|
||||
"stats": "能力値",
|
||||
"experience": "経験値",
|
||||
"xpNeededForNextLevel": "次のレベルまでの必要XP",
|
||||
"spellBook": "スキル",
|
||||
"noSpellsYet": "習得したスキルがありません",
|
||||
"equipment": "Equipment",
|
||||
"inventory": "Inventory",
|
||||
"encumbrance": "Encumbrance",
|
||||
"equipment": "装備",
|
||||
"inventory": "インベントリ",
|
||||
"encumbrance": "積載量",
|
||||
"combatLog": "戦闘ログ",
|
||||
"plotDevelopment": "Plot Development",
|
||||
"quests": "Quests",
|
||||
"traitName": "Name",
|
||||
"traitRace": "Race",
|
||||
"traitClass": "Class",
|
||||
"traitLevel": "Level",
|
||||
"plotDevelopment": "ストーリー進行",
|
||||
"quests": "クエスト",
|
||||
"traitName": "名前",
|
||||
"traitRace": "種族",
|
||||
"traitClass": "職業",
|
||||
"traitLevel": "レベル",
|
||||
"statStr": "STR",
|
||||
"statCon": "CON",
|
||||
"statDex": "DEX",
|
||||
"statInt": "INT",
|
||||
"statWis": "WIS",
|
||||
"statCha": "CHA",
|
||||
"statHpMax": "HP Max",
|
||||
"statMpMax": "MP Max",
|
||||
"equipWeapon": "Weapon",
|
||||
"equipShield": "Shield",
|
||||
"equipHelm": "Helm",
|
||||
"equipHauberk": "Hauberk",
|
||||
"equipBrassairts": "Brassairts",
|
||||
"equipVambraces": "Vambraces",
|
||||
"equipGauntlets": "Gauntlets",
|
||||
"equipGambeson": "Gambeson",
|
||||
"equipCuisses": "Cuisses",
|
||||
"equipGreaves": "Greaves",
|
||||
"equipSollerets": "Sollerets",
|
||||
"statHpMax": "HP最大",
|
||||
"statMpMax": "MP最大",
|
||||
"equipWeapon": "武器",
|
||||
"equipShield": "盾",
|
||||
"equipHelm": "兜",
|
||||
"equipHauberk": "鎧",
|
||||
"equipBrassairts": "肩当て",
|
||||
"equipVambraces": "腕甲",
|
||||
"equipGauntlets": "篭手",
|
||||
"equipGambeson": "防護服",
|
||||
"equipCuisses": "腿当て",
|
||||
"equipGreaves": "脛当て",
|
||||
"equipSollerets": "鉄靴",
|
||||
"gold": "コイン",
|
||||
"goldAmount": "コイン: {amount}",
|
||||
"prologue": "Prologue",
|
||||
"actNumber": "Act {number}",
|
||||
"noActiveQuests": "No active quests",
|
||||
"questNumber": "Quest #{number}",
|
||||
"prologue": "プロローグ",
|
||||
"actNumber": "第{number}幕",
|
||||
"noActiveQuests": "進行中のクエストなし",
|
||||
"questNumber": "クエスト #{number}",
|
||||
"welcomeMessage": "ASCII NEVER DIEへようこそ!",
|
||||
"noSavedGames": "No saved games found.",
|
||||
"loadError": "Failed to load save file: {error}",
|
||||
"name": "Name",
|
||||
"generateName": "Generate Name",
|
||||
"total": "Total",
|
||||
"noSavedGames": "セーブデータがありません。",
|
||||
"loadError": "セーブファイルの読み込みに失敗しました: {error}",
|
||||
"name": "名前",
|
||||
"generateName": "名前を生成",
|
||||
"total": "合計",
|
||||
"unroll": "元に戻す",
|
||||
"roll": "Roll",
|
||||
"race": "Race",
|
||||
"classTitle": "Class",
|
||||
"percentComplete": "{percent}% complete",
|
||||
"newCharacterTitle": "ASCII NEVER DIE - New Character",
|
||||
"soldButton": "Sold!",
|
||||
"roll": "ロール",
|
||||
"race": "種族",
|
||||
"classTitle": "職業",
|
||||
"percentComplete": "{percent}% 完了",
|
||||
"newCharacterTitle": "アスキー ネバー ダイ - 新規キャラクター",
|
||||
"soldButton": "決定!",
|
||||
|
||||
"endingCongratulations": "★ おめでとうございます ★",
|
||||
"endingGameComplete": "ゲームをクリアしました!",
|
||||
@@ -95,55 +95,130 @@
|
||||
"endingTapToSkip": "タップでスキップ",
|
||||
"endingHoldToSpeedUp": "長押しで高速スクロール",
|
||||
|
||||
"menuTitle": "MENU",
|
||||
"optionsTitle": "OPTIONS",
|
||||
"soundTitle": "SOUND",
|
||||
"controlSection": "CONTROL",
|
||||
"infoSection": "INFO",
|
||||
"settingsSection": "SETTINGS",
|
||||
"saveExitSection": "SAVE / EXIT",
|
||||
"menuTitle": "メニュー",
|
||||
"optionsTitle": "オプション",
|
||||
"soundTitle": "サウンド",
|
||||
"controlSection": "操作",
|
||||
"infoSection": "情報",
|
||||
"settingsSection": "設定",
|
||||
"saveExitSection": "セーブ / 終了",
|
||||
"ok": "OK",
|
||||
"rechargeButton": "RECHARGE",
|
||||
"createButton": "CREATE",
|
||||
"previewTitle": "PREVIEW",
|
||||
"nameTitle": "NAME",
|
||||
"statsTitle": "STATS",
|
||||
"raceTitle": "RACE",
|
||||
"classSection": "CLASS",
|
||||
"rechargeButton": "チャージ",
|
||||
"createButton": "作成",
|
||||
"previewTitle": "プレビュー",
|
||||
"nameTitle": "名前",
|
||||
"statsTitle": "能力値",
|
||||
"raceTitle": "種族",
|
||||
"classSection": "職業",
|
||||
"bgmLabel": "BGM",
|
||||
"sfxLabel": "SFX",
|
||||
"sfxLabel": "効果音",
|
||||
"hpLabel": "HP",
|
||||
"mpLabel": "MP",
|
||||
"expLabel": "EXP",
|
||||
"notifyLevelUp": "LEVEL UP!",
|
||||
"notifyLevel": "Level {level}",
|
||||
"notifyQuestComplete": "QUEST COMPLETE!",
|
||||
"notifyPrologueComplete": "PROLOGUE COMPLETE!",
|
||||
"notifyActComplete": "ACT {number} COMPLETE!",
|
||||
"notifyNewSpell": "NEW SPELL!",
|
||||
"notifyNewEquipment": "NEW EQUIPMENT!",
|
||||
"notifyBossDefeated": "BOSS DEFEATED!",
|
||||
"rechargeRollsTitle": "RECHARGE ROLLS",
|
||||
"rechargeRollsFree": "Recharge 5 rolls for free?",
|
||||
"rechargeRollsAd": "Watch an ad to recharge 5 rolls?",
|
||||
"debugTitle": "DEBUG",
|
||||
"debugCheatsTitle": "DEBUG CHEATS",
|
||||
"debugToolsTitle": "DEBUG TOOLS",
|
||||
"debugDeveloperTools": "DEVELOPER TOOLS",
|
||||
"debugSkipTask": "SKIP TASK (L+1)",
|
||||
"debugSkipTaskDesc": "Complete task instantly",
|
||||
"debugSkipQuest": "SKIP QUEST (Q!)",
|
||||
"debugSkipQuestDesc": "Complete quest instantly",
|
||||
"debugSkipAct": "SKIP ACT (P!)",
|
||||
"debugSkipActDesc": "Complete act instantly",
|
||||
"debugCreateTestCharacter": "CREATE TEST CHARACTER",
|
||||
"debugCreateTestCharacterDesc": "Register Level 100 character to Hall of Fame",
|
||||
"debugCreateTestCharacterTitle": "CREATE TEST CHARACTER?",
|
||||
"debugCreateTestCharacterMessage": "Current character will be converted to Level 100\nand registered to the Hall of Fame.\n\n⚠️ Current save file will be deleted.\nThis action cannot be undone.",
|
||||
"debugTurbo": "DEBUG: TURBO (20x)",
|
||||
"debugIapPurchased": "IAP PURCHASED",
|
||||
"debugIapPurchasedDesc": "ON: Behave as paid user (ads removed)",
|
||||
"debugOfflineHours": "OFFLINE HOURS",
|
||||
"debugOfflineHoursDesc": "Test return rewards (applies on restart)",
|
||||
"debugTestCharacterDesc": "Modify current character to Level 100\nand register to the Hall of Fame."
|
||||
"notifyLevelUp": "レベルアップ!",
|
||||
"notifyLevel": "レベル {level}",
|
||||
"notifyQuestComplete": "クエスト完了!",
|
||||
"notifyPrologueComplete": "プロローグ完了!",
|
||||
"notifyActComplete": "第{number}幕 完了!",
|
||||
"notifyNewSpell": "新しいスキル!",
|
||||
"notifyNewEquipment": "新しい装備!",
|
||||
"notifyBossDefeated": "ボス撃破!",
|
||||
"rechargeRollsTitle": "ロール回数チャージ",
|
||||
"rechargeRollsFree": "無料で5回チャージしますか?",
|
||||
"rechargeRollsAd": "広告を見て5回チャージしますか?",
|
||||
"debugTitle": "デバッグ",
|
||||
"debugCheatsTitle": "デバッグチート",
|
||||
"debugToolsTitle": "デバッグツール",
|
||||
"debugDeveloperTools": "開発者ツール",
|
||||
"debugSkipTask": "タスクスキップ (L+1)",
|
||||
"debugSkipTaskDesc": "タスクを即時完了",
|
||||
"debugSkipQuest": "クエストスキップ (Q!)",
|
||||
"debugSkipQuestDesc": "クエストを即時完了",
|
||||
"debugSkipAct": "アクトスキップ (P!)",
|
||||
"debugSkipActDesc": "アクトを即時完了",
|
||||
"debugCreateTestCharacter": "テストキャラクター作成",
|
||||
"debugCreateTestCharacterDesc": "レベル100キャラクターを殿堂に登録",
|
||||
"debugCreateTestCharacterTitle": "テストキャラクターを作成しますか?",
|
||||
"debugCreateTestCharacterMessage": "現在のキャラクターがレベル100に変換され\n殿堂に登録されます。\n\n⚠️ 現在のセーブファイルは削除されます。\nこの操作は元に戻せません。",
|
||||
"debugTurbo": "デバッグ: ターボ (20x)",
|
||||
"debugIapPurchased": "IAP購入済み",
|
||||
"debugIapPurchasedDesc": "ON: 有料ユーザーとして動作(広告非表示)",
|
||||
"debugOfflineHours": "オフライン時間",
|
||||
"debugOfflineHoursDesc": "復帰報酬テスト(再起動時に適用)",
|
||||
"debugTestCharacterDesc": "現在のキャラクターをレベル100に変更して\n殿堂に登録します。",
|
||||
|
||||
"arenaTitle": "ローカルアリーナ",
|
||||
"arenaSelectFighter": "ファイターを選択",
|
||||
"arenaEmptyTitle": "ヒーローが不足しています",
|
||||
"arenaEmptyHint": "2人以上のキャラでクリアしてください",
|
||||
"arenaSetupTitle": "アリーナ設定",
|
||||
"arenaStartBattle": "バトル開始",
|
||||
"arenaBattleTitle": "アリーナバトル",
|
||||
"arenaMyEquipment": "自分の装備",
|
||||
"arenaEnemyEquipment": "敵の装備",
|
||||
"arenaSelected": "選択済み",
|
||||
"arenaRecommended": "おすすめ",
|
||||
"arenaWeaponLocked": "ロック",
|
||||
"arenaVictory": "勝利!",
|
||||
"arenaDefeat": "敗北...",
|
||||
"arenaEquipmentExchange": "装備交換",
|
||||
"arenaTurns": "ターン",
|
||||
"arenaWinner": "勝者",
|
||||
"arenaLoser": "敗者",
|
||||
"arenaDefeatedIn": "{winner}が{loser}を{turns}ターンで撃破",
|
||||
"arenaScoreGain": "+{score}獲得予定",
|
||||
"arenaScoreLose": "{score}損失予定",
|
||||
"arenaEvenTrade": "等価交換",
|
||||
"arenaScore": "スコア",
|
||||
|
||||
"statsStatistics": "統計",
|
||||
"statsSession": "セッション",
|
||||
"statsAccumulated": "累積",
|
||||
"statsCombat": "戦闘",
|
||||
"statsPlayTime": "プレイ時間",
|
||||
"statsMonstersKilled": "倒したモンスター",
|
||||
"statsBossesDefeated": "ボス討伐",
|
||||
"statsDeaths": "死亡回数",
|
||||
"statsDamage": "ダメージ",
|
||||
"statsDamageDealt": "与えたダメージ",
|
||||
"statsDamageTaken": "受けたダメージ",
|
||||
"statsAverageDps": "平均DPS",
|
||||
"statsSkills": "スキル",
|
||||
"statsSkillsUsed": "スキル使用",
|
||||
"statsCriticalHits": "クリティカルヒット",
|
||||
"statsMaxCriticalStreak": "最大連続クリティカル",
|
||||
"statsCriticalRate": "クリティカル率",
|
||||
"statsEconomy": "経済",
|
||||
"statsGoldEarned": "獲得ゴールド",
|
||||
"statsGoldSpent": "消費ゴールド",
|
||||
"statsItemsSold": "売却アイテム",
|
||||
"statsPotionsUsed": "ポーション使用",
|
||||
"statsProgress": "進行",
|
||||
"statsLevelUps": "レベルアップ",
|
||||
"statsQuestsCompleted": "完了したクエスト",
|
||||
"statsRecords": "記録",
|
||||
"statsHighestLevel": "最高レベル",
|
||||
"statsHighestGoldHeld": "最大所持ゴールド",
|
||||
"statsBestCriticalStreak": "最高連続クリティカル",
|
||||
"statsTotalPlay": "総プレイ",
|
||||
"statsTotalPlayTime": "総プレイ時間",
|
||||
"statsGamesStarted": "開始したゲーム",
|
||||
"statsGamesCompleted": "クリアしたゲーム",
|
||||
"statsCompletionRate": "クリア率",
|
||||
"statsTotalCombat": "総戦闘",
|
||||
"statsTotalDeaths": "総死亡",
|
||||
"statsTotalLevelUps": "総レベルアップ",
|
||||
"statsTotalDamage": "総ダメージ",
|
||||
"statsTotalSkills": "総スキル",
|
||||
"statsTotalEconomy": "総経済",
|
||||
|
||||
"notifyLevelUpLabel": "レベルアップ",
|
||||
"notifyQuestDoneLabel": "クエスト完了",
|
||||
"notifyActClearLabel": "幕完了",
|
||||
"notifyNewSpellLabel": "新しいスキル",
|
||||
"notifyNewItemLabel": "新しいアイテム",
|
||||
"notifyBossSlainLabel": "ボス撃破",
|
||||
"notifySavedLabel": "セーブ済み",
|
||||
"notifyInfoLabel": "情報",
|
||||
"notifyWarningLabel": "警告"
|
||||
}
|
||||
|
||||
@@ -145,5 +145,80 @@
|
||||
"debugIapPurchasedDesc": "ON: 유료 유저로 동작 (광고 제거)",
|
||||
"debugOfflineHours": "오프라인 시간",
|
||||
"debugOfflineHoursDesc": "복귀 보상 테스트 (재시작 시 적용)",
|
||||
"debugTestCharacterDesc": "현재 캐릭터를 레벨 100으로 수정하여\n명예의 전당에 등록합니다."
|
||||
"debugTestCharacterDesc": "현재 캐릭터를 레벨 100으로 수정하여\n명예의 전당에 등록합니다.",
|
||||
|
||||
"arenaTitle": "로컬 아레나",
|
||||
"arenaSelectFighter": "전사를 선택하세요",
|
||||
"arenaEmptyTitle": "영웅이 부족합니다",
|
||||
"arenaEmptyHint": "2명 이상 캐릭터로 클리어하세요",
|
||||
"arenaSetupTitle": "아레나 설정",
|
||||
"arenaStartBattle": "전투 시작",
|
||||
"arenaBattleTitle": "아레나 전투",
|
||||
"arenaMyEquipment": "내 장비",
|
||||
"arenaEnemyEquipment": "상대 장비",
|
||||
"arenaSelected": "선택됨",
|
||||
"arenaRecommended": "추천",
|
||||
"arenaWeaponLocked": "잠김",
|
||||
"arenaVictory": "승리!",
|
||||
"arenaDefeat": "패배...",
|
||||
"arenaEquipmentExchange": "장비 교환",
|
||||
"arenaTurns": "턴",
|
||||
"arenaWinner": "승자",
|
||||
"arenaLoser": "패자",
|
||||
"arenaDefeatedIn": "{winner}이(가) {loser}을(를) {turns}턴 만에 격파",
|
||||
"arenaScoreGain": "+{score} 획득 예정",
|
||||
"arenaScoreLose": "{score} 손실 예정",
|
||||
"arenaEvenTrade": "등가 교환",
|
||||
"arenaScore": "점수",
|
||||
|
||||
"statsStatistics": "통계",
|
||||
"statsSession": "세션",
|
||||
"statsAccumulated": "누적",
|
||||
"statsCombat": "전투",
|
||||
"statsPlayTime": "플레이 시간",
|
||||
"statsMonstersKilled": "처치한 몬스터",
|
||||
"statsBossesDefeated": "보스 처치",
|
||||
"statsDeaths": "사망 횟수",
|
||||
"statsDamage": "데미지",
|
||||
"statsDamageDealt": "입힌 데미지",
|
||||
"statsDamageTaken": "받은 데미지",
|
||||
"statsAverageDps": "평균 DPS",
|
||||
"statsSkills": "스킬",
|
||||
"statsSkillsUsed": "스킬 사용",
|
||||
"statsCriticalHits": "크리티컬 히트",
|
||||
"statsMaxCriticalStreak": "최대 연속 크리티컬",
|
||||
"statsCriticalRate": "크리티컬 비율",
|
||||
"statsEconomy": "경제",
|
||||
"statsGoldEarned": "획득 골드",
|
||||
"statsGoldSpent": "소비 골드",
|
||||
"statsItemsSold": "판매 아이템",
|
||||
"statsPotionsUsed": "물약 사용",
|
||||
"statsProgress": "진행",
|
||||
"statsLevelUps": "레벨업",
|
||||
"statsQuestsCompleted": "완료한 퀘스트",
|
||||
"statsRecords": "기록",
|
||||
"statsHighestLevel": "최고 레벨",
|
||||
"statsHighestGoldHeld": "최대 보유 골드",
|
||||
"statsBestCriticalStreak": "최고 연속 크리티컬",
|
||||
"statsTotalPlay": "총 플레이",
|
||||
"statsTotalPlayTime": "총 플레이 시간",
|
||||
"statsGamesStarted": "시작한 게임",
|
||||
"statsGamesCompleted": "클리어한 게임",
|
||||
"statsCompletionRate": "클리어율",
|
||||
"statsTotalCombat": "총 전투",
|
||||
"statsTotalDeaths": "총 사망",
|
||||
"statsTotalLevelUps": "총 레벨업",
|
||||
"statsTotalDamage": "총 데미지",
|
||||
"statsTotalSkills": "총 스킬",
|
||||
"statsTotalEconomy": "총 경제",
|
||||
|
||||
"notifyLevelUpLabel": "레벨 업",
|
||||
"notifyQuestDoneLabel": "퀘스트 완료",
|
||||
"notifyActClearLabel": "막 완료",
|
||||
"notifyNewSpellLabel": "새 주문",
|
||||
"notifyNewItemLabel": "새 아이템",
|
||||
"notifyBossSlainLabel": "보스 처치",
|
||||
"notifySavedLabel": "저장됨",
|
||||
"notifyInfoLabel": "정보",
|
||||
"notifyWarningLabel": "경고"
|
||||
}
|
||||
|
||||
@@ -956,6 +956,438 @@ abstract class L10n {
|
||||
/// In en, this message translates to:
|
||||
/// **'Modify current character to Level 100\nand register to the Hall of Fame.'**
|
||||
String get debugTestCharacterDesc;
|
||||
|
||||
/// Arena main screen title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'LOCAL ARENA'**
|
||||
String get arenaTitle;
|
||||
|
||||
/// Arena character selection subtitle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SELECT YOUR FIGHTER'**
|
||||
String get arenaSelectFighter;
|
||||
|
||||
/// Arena empty state title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not enough heroes'**
|
||||
String get arenaEmptyTitle;
|
||||
|
||||
/// Arena empty state hint
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear the game with 2+ characters'**
|
||||
String get arenaEmptyHint;
|
||||
|
||||
/// Arena setup screen title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ARENA SETUP'**
|
||||
String get arenaSetupTitle;
|
||||
|
||||
/// Start battle button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'START BATTLE'**
|
||||
String get arenaStartBattle;
|
||||
|
||||
/// Arena battle screen title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ARENA BATTLE'**
|
||||
String get arenaBattleTitle;
|
||||
|
||||
/// My equipment header
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'MY EQUIPMENT'**
|
||||
String get arenaMyEquipment;
|
||||
|
||||
/// Enemy equipment header
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ENEMY EQUIPMENT'**
|
||||
String get arenaEnemyEquipment;
|
||||
|
||||
/// Selected slot label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SELECTED'**
|
||||
String get arenaSelected;
|
||||
|
||||
/// Recommended slot label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'BEST'**
|
||||
String get arenaRecommended;
|
||||
|
||||
/// Weapon slot locked label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'LOCKED'**
|
||||
String get arenaWeaponLocked;
|
||||
|
||||
/// Arena victory title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'VICTORY!'**
|
||||
String get arenaVictory;
|
||||
|
||||
/// Arena defeat title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'DEFEAT...'**
|
||||
String get arenaDefeat;
|
||||
|
||||
/// Equipment exchange section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'EQUIPMENT EXCHANGE'**
|
||||
String get arenaEquipmentExchange;
|
||||
|
||||
/// Turns label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'TURNS'**
|
||||
String get arenaTurns;
|
||||
|
||||
/// Winner label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'WINNER'**
|
||||
String get arenaWinner;
|
||||
|
||||
/// Loser label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'LOSER'**
|
||||
String get arenaLoser;
|
||||
|
||||
/// Battle summary text
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{winner} defeated {loser} in {turns} TURNS'**
|
||||
String arenaDefeatedIn(String winner, String loser, int turns);
|
||||
|
||||
/// Score gain prediction
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You will GAIN +{score}'**
|
||||
String arenaScoreGain(int score);
|
||||
|
||||
/// Score loss prediction
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You will LOSE {score}'**
|
||||
String arenaScoreLose(int score);
|
||||
|
||||
/// Even trade label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Even trade'**
|
||||
String get arenaEvenTrade;
|
||||
|
||||
/// Score label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SCORE'**
|
||||
String get arenaScore;
|
||||
|
||||
/// Statistics dialog title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Statistics'**
|
||||
String get statsStatistics;
|
||||
|
||||
/// Session tab label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Session'**
|
||||
String get statsSession;
|
||||
|
||||
/// Accumulated tab label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total'**
|
||||
String get statsAccumulated;
|
||||
|
||||
/// Combat section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Combat'**
|
||||
String get statsCombat;
|
||||
|
||||
/// Play time label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Play Time'**
|
||||
String get statsPlayTime;
|
||||
|
||||
/// Monsters killed label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Monsters Killed'**
|
||||
String get statsMonstersKilled;
|
||||
|
||||
/// Bosses defeated label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bosses Defeated'**
|
||||
String get statsBossesDefeated;
|
||||
|
||||
/// Deaths label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Deaths'**
|
||||
String get statsDeaths;
|
||||
|
||||
/// Damage section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Damage'**
|
||||
String get statsDamage;
|
||||
|
||||
/// Damage dealt label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Damage Dealt'**
|
||||
String get statsDamageDealt;
|
||||
|
||||
/// Damage taken label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Damage Taken'**
|
||||
String get statsDamageTaken;
|
||||
|
||||
/// Average DPS label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Average DPS'**
|
||||
String get statsAverageDps;
|
||||
|
||||
/// Skills section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Skills'**
|
||||
String get statsSkills;
|
||||
|
||||
/// Skills used label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Skills Used'**
|
||||
String get statsSkillsUsed;
|
||||
|
||||
/// Critical hits label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Critical Hits'**
|
||||
String get statsCriticalHits;
|
||||
|
||||
/// Max critical streak label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Max Critical Streak'**
|
||||
String get statsMaxCriticalStreak;
|
||||
|
||||
/// Critical rate label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Critical Rate'**
|
||||
String get statsCriticalRate;
|
||||
|
||||
/// Economy section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Economy'**
|
||||
String get statsEconomy;
|
||||
|
||||
/// Gold earned label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Gold Earned'**
|
||||
String get statsGoldEarned;
|
||||
|
||||
/// Gold spent label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Gold Spent'**
|
||||
String get statsGoldSpent;
|
||||
|
||||
/// Items sold label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Items Sold'**
|
||||
String get statsItemsSold;
|
||||
|
||||
/// Potions used label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Potions Used'**
|
||||
String get statsPotionsUsed;
|
||||
|
||||
/// Progress section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Progress'**
|
||||
String get statsProgress;
|
||||
|
||||
/// Level ups label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Level Ups'**
|
||||
String get statsLevelUps;
|
||||
|
||||
/// Quests completed label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Quests Completed'**
|
||||
String get statsQuestsCompleted;
|
||||
|
||||
/// Records section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Records'**
|
||||
String get statsRecords;
|
||||
|
||||
/// Highest level label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Highest Level'**
|
||||
String get statsHighestLevel;
|
||||
|
||||
/// Highest gold held label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Highest Gold Held'**
|
||||
String get statsHighestGoldHeld;
|
||||
|
||||
/// Best critical streak label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best Critical Streak'**
|
||||
String get statsBestCriticalStreak;
|
||||
|
||||
/// Total play section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Play'**
|
||||
String get statsTotalPlay;
|
||||
|
||||
/// Total play time label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Play Time'**
|
||||
String get statsTotalPlayTime;
|
||||
|
||||
/// Games started label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Games Started'**
|
||||
String get statsGamesStarted;
|
||||
|
||||
/// Games completed label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Games Completed'**
|
||||
String get statsGamesCompleted;
|
||||
|
||||
/// Completion rate label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Completion Rate'**
|
||||
String get statsCompletionRate;
|
||||
|
||||
/// Total combat section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Combat'**
|
||||
String get statsTotalCombat;
|
||||
|
||||
/// Total deaths label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Deaths'**
|
||||
String get statsTotalDeaths;
|
||||
|
||||
/// Total level ups label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Level Ups'**
|
||||
String get statsTotalLevelUps;
|
||||
|
||||
/// Total damage section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Damage'**
|
||||
String get statsTotalDamage;
|
||||
|
||||
/// Total skills section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Skills'**
|
||||
String get statsTotalSkills;
|
||||
|
||||
/// Total economy section title
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Total Economy'**
|
||||
String get statsTotalEconomy;
|
||||
|
||||
/// Level up notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'LEVEL UP'**
|
||||
String get notifyLevelUpLabel;
|
||||
|
||||
/// Quest done notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'QUEST DONE'**
|
||||
String get notifyQuestDoneLabel;
|
||||
|
||||
/// Act clear notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ACT CLEAR'**
|
||||
String get notifyActClearLabel;
|
||||
|
||||
/// New spell notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'NEW SPELL'**
|
||||
String get notifyNewSpellLabel;
|
||||
|
||||
/// New item notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'NEW ITEM'**
|
||||
String get notifyNewItemLabel;
|
||||
|
||||
/// Boss slain notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'BOSS SLAIN'**
|
||||
String get notifyBossSlainLabel;
|
||||
|
||||
/// Game saved notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SAVED'**
|
||||
String get notifySavedLabel;
|
||||
|
||||
/// Info notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'INFO'**
|
||||
String get notifyInfoLabel;
|
||||
|
||||
/// Warning notification type label
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'WARNING'**
|
||||
String get notifyWarningLabel;
|
||||
}
|
||||
|
||||
class _L10nDelegate extends LocalizationsDelegate<L10n> {
|
||||
|
||||
@@ -458,4 +458,226 @@ class L10nEn extends L10n {
|
||||
@override
|
||||
String get debugTestCharacterDesc =>
|
||||
'Modify current character to Level 100\nand register to the Hall of Fame.';
|
||||
|
||||
@override
|
||||
String get arenaTitle => 'LOCAL ARENA';
|
||||
|
||||
@override
|
||||
String get arenaSelectFighter => 'SELECT YOUR FIGHTER';
|
||||
|
||||
@override
|
||||
String get arenaEmptyTitle => 'Not enough heroes';
|
||||
|
||||
@override
|
||||
String get arenaEmptyHint => 'Clear the game with 2+ characters';
|
||||
|
||||
@override
|
||||
String get arenaSetupTitle => 'ARENA SETUP';
|
||||
|
||||
@override
|
||||
String get arenaStartBattle => 'START BATTLE';
|
||||
|
||||
@override
|
||||
String get arenaBattleTitle => 'ARENA BATTLE';
|
||||
|
||||
@override
|
||||
String get arenaMyEquipment => 'MY EQUIPMENT';
|
||||
|
||||
@override
|
||||
String get arenaEnemyEquipment => 'ENEMY EQUIPMENT';
|
||||
|
||||
@override
|
||||
String get arenaSelected => 'SELECTED';
|
||||
|
||||
@override
|
||||
String get arenaRecommended => 'BEST';
|
||||
|
||||
@override
|
||||
String get arenaWeaponLocked => 'LOCKED';
|
||||
|
||||
@override
|
||||
String get arenaVictory => 'VICTORY!';
|
||||
|
||||
@override
|
||||
String get arenaDefeat => 'DEFEAT...';
|
||||
|
||||
@override
|
||||
String get arenaEquipmentExchange => 'EQUIPMENT EXCHANGE';
|
||||
|
||||
@override
|
||||
String get arenaTurns => 'TURNS';
|
||||
|
||||
@override
|
||||
String get arenaWinner => 'WINNER';
|
||||
|
||||
@override
|
||||
String get arenaLoser => 'LOSER';
|
||||
|
||||
@override
|
||||
String arenaDefeatedIn(String winner, String loser, int turns) {
|
||||
return '$winner defeated $loser in $turns TURNS';
|
||||
}
|
||||
|
||||
@override
|
||||
String arenaScoreGain(int score) {
|
||||
return 'You will GAIN +$score';
|
||||
}
|
||||
|
||||
@override
|
||||
String arenaScoreLose(int score) {
|
||||
return 'You will LOSE $score';
|
||||
}
|
||||
|
||||
@override
|
||||
String get arenaEvenTrade => 'Even trade';
|
||||
|
||||
@override
|
||||
String get arenaScore => 'SCORE';
|
||||
|
||||
@override
|
||||
String get statsStatistics => 'Statistics';
|
||||
|
||||
@override
|
||||
String get statsSession => 'Session';
|
||||
|
||||
@override
|
||||
String get statsAccumulated => 'Total';
|
||||
|
||||
@override
|
||||
String get statsCombat => 'Combat';
|
||||
|
||||
@override
|
||||
String get statsPlayTime => 'Play Time';
|
||||
|
||||
@override
|
||||
String get statsMonstersKilled => 'Monsters Killed';
|
||||
|
||||
@override
|
||||
String get statsBossesDefeated => 'Bosses Defeated';
|
||||
|
||||
@override
|
||||
String get statsDeaths => 'Deaths';
|
||||
|
||||
@override
|
||||
String get statsDamage => 'Damage';
|
||||
|
||||
@override
|
||||
String get statsDamageDealt => 'Damage Dealt';
|
||||
|
||||
@override
|
||||
String get statsDamageTaken => 'Damage Taken';
|
||||
|
||||
@override
|
||||
String get statsAverageDps => 'Average DPS';
|
||||
|
||||
@override
|
||||
String get statsSkills => 'Skills';
|
||||
|
||||
@override
|
||||
String get statsSkillsUsed => 'Skills Used';
|
||||
|
||||
@override
|
||||
String get statsCriticalHits => 'Critical Hits';
|
||||
|
||||
@override
|
||||
String get statsMaxCriticalStreak => 'Max Critical Streak';
|
||||
|
||||
@override
|
||||
String get statsCriticalRate => 'Critical Rate';
|
||||
|
||||
@override
|
||||
String get statsEconomy => 'Economy';
|
||||
|
||||
@override
|
||||
String get statsGoldEarned => 'Gold Earned';
|
||||
|
||||
@override
|
||||
String get statsGoldSpent => 'Gold Spent';
|
||||
|
||||
@override
|
||||
String get statsItemsSold => 'Items Sold';
|
||||
|
||||
@override
|
||||
String get statsPotionsUsed => 'Potions Used';
|
||||
|
||||
@override
|
||||
String get statsProgress => 'Progress';
|
||||
|
||||
@override
|
||||
String get statsLevelUps => 'Level Ups';
|
||||
|
||||
@override
|
||||
String get statsQuestsCompleted => 'Quests Completed';
|
||||
|
||||
@override
|
||||
String get statsRecords => 'Records';
|
||||
|
||||
@override
|
||||
String get statsHighestLevel => 'Highest Level';
|
||||
|
||||
@override
|
||||
String get statsHighestGoldHeld => 'Highest Gold Held';
|
||||
|
||||
@override
|
||||
String get statsBestCriticalStreak => 'Best Critical Streak';
|
||||
|
||||
@override
|
||||
String get statsTotalPlay => 'Total Play';
|
||||
|
||||
@override
|
||||
String get statsTotalPlayTime => 'Total Play Time';
|
||||
|
||||
@override
|
||||
String get statsGamesStarted => 'Games Started';
|
||||
|
||||
@override
|
||||
String get statsGamesCompleted => 'Games Completed';
|
||||
|
||||
@override
|
||||
String get statsCompletionRate => 'Completion Rate';
|
||||
|
||||
@override
|
||||
String get statsTotalCombat => 'Total Combat';
|
||||
|
||||
@override
|
||||
String get statsTotalDeaths => 'Total Deaths';
|
||||
|
||||
@override
|
||||
String get statsTotalLevelUps => 'Total Level Ups';
|
||||
|
||||
@override
|
||||
String get statsTotalDamage => 'Total Damage';
|
||||
|
||||
@override
|
||||
String get statsTotalSkills => 'Total Skills';
|
||||
|
||||
@override
|
||||
String get statsTotalEconomy => 'Total Economy';
|
||||
|
||||
@override
|
||||
String get notifyLevelUpLabel => 'LEVEL UP';
|
||||
|
||||
@override
|
||||
String get notifyQuestDoneLabel => 'QUEST DONE';
|
||||
|
||||
@override
|
||||
String get notifyActClearLabel => 'ACT CLEAR';
|
||||
|
||||
@override
|
||||
String get notifyNewSpellLabel => 'NEW SPELL';
|
||||
|
||||
@override
|
||||
String get notifyNewItemLabel => 'NEW ITEM';
|
||||
|
||||
@override
|
||||
String get notifyBossSlainLabel => 'BOSS SLAIN';
|
||||
|
||||
@override
|
||||
String get notifySavedLabel => 'SAVED';
|
||||
|
||||
@override
|
||||
String get notifyInfoLabel => 'INFO';
|
||||
|
||||
@override
|
||||
String get notifyWarningLabel => 'WARNING';
|
||||
}
|
||||
|
||||
@@ -12,75 +12,75 @@ class L10nJa extends L10n {
|
||||
String get appTitle => 'アスキー ネバー ダイ';
|
||||
|
||||
@override
|
||||
String get tagNoNetwork => 'No network';
|
||||
String get tagNoNetwork => 'オフライン';
|
||||
|
||||
@override
|
||||
String get tagIdleRpg => 'Idle RPG loop';
|
||||
String get tagIdleRpg => '放置型RPG';
|
||||
|
||||
@override
|
||||
String get tagLocalSaves => 'Local saves';
|
||||
String get tagLocalSaves => 'ローカル保存';
|
||||
|
||||
@override
|
||||
String get newCharacter => 'New character';
|
||||
String get newCharacter => '新規キャラクター';
|
||||
|
||||
@override
|
||||
String get loadSave => 'Load save';
|
||||
String get loadSave => 'ロード';
|
||||
|
||||
@override
|
||||
String get loadGame => 'Load Game';
|
||||
String get loadGame => 'ゲームをロード';
|
||||
|
||||
@override
|
||||
String get viewBuildPlan => 'View build plan';
|
||||
String get viewBuildPlan => 'ビルド計画を見る';
|
||||
|
||||
@override
|
||||
String get buildRoadmap => 'Build roadmap';
|
||||
String get buildRoadmap => 'ビルドロードマップ';
|
||||
|
||||
@override
|
||||
String get techStack => 'Tech stack';
|
||||
String get techStack => '技術スタック';
|
||||
|
||||
@override
|
||||
String get cancel => 'Cancel';
|
||||
String get cancel => 'キャンセル';
|
||||
|
||||
@override
|
||||
String get exitGame => 'Exit Game';
|
||||
String get exitGame => 'ゲーム終了';
|
||||
|
||||
@override
|
||||
String get saveProgressQuestion => 'Save your progress before leaving?';
|
||||
String get saveProgressQuestion => '終了する前にセーブしますか?';
|
||||
|
||||
@override
|
||||
String get exitWithoutSaving => 'Exit without saving';
|
||||
String get exitWithoutSaving => 'セーブせずに終了';
|
||||
|
||||
@override
|
||||
String get saveAndExit => 'Save and Exit';
|
||||
String get saveAndExit => 'セーブして終了';
|
||||
|
||||
@override
|
||||
String progressQuestTitle(String name) {
|
||||
return 'ASCII NEVER DIE - $name';
|
||||
return 'アスキー ネバー ダイ - $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get levelUp => 'Level Up';
|
||||
String get levelUp => 'レベルアップ';
|
||||
|
||||
@override
|
||||
String get completeQuest => 'Complete Quest';
|
||||
String get completeQuest => 'クエスト完了';
|
||||
|
||||
@override
|
||||
String get completePlot => 'Complete Plot';
|
||||
String get completePlot => 'プロット完了';
|
||||
|
||||
@override
|
||||
String get characterSheet => 'Character Sheet';
|
||||
String get characterSheet => 'キャラクターシート';
|
||||
|
||||
@override
|
||||
String get traits => 'Traits';
|
||||
String get traits => '特性';
|
||||
|
||||
@override
|
||||
String get stats => 'Stats';
|
||||
String get stats => '能力値';
|
||||
|
||||
@override
|
||||
String get experience => 'Experience';
|
||||
String get experience => '経験値';
|
||||
|
||||
@override
|
||||
String get xpNeededForNextLevel => 'XP needed for next level';
|
||||
String get xpNeededForNextLevel => '次のレベルまでの必要XP';
|
||||
|
||||
@override
|
||||
String get spellBook => 'スキル';
|
||||
@@ -89,34 +89,34 @@ class L10nJa extends L10n {
|
||||
String get noSpellsYet => '習得したスキルがありません';
|
||||
|
||||
@override
|
||||
String get equipment => 'Equipment';
|
||||
String get equipment => '装備';
|
||||
|
||||
@override
|
||||
String get inventory => 'Inventory';
|
||||
String get inventory => 'インベントリ';
|
||||
|
||||
@override
|
||||
String get encumbrance => 'Encumbrance';
|
||||
String get encumbrance => '積載量';
|
||||
|
||||
@override
|
||||
String get combatLog => '戦闘ログ';
|
||||
|
||||
@override
|
||||
String get plotDevelopment => 'Plot Development';
|
||||
String get plotDevelopment => 'ストーリー進行';
|
||||
|
||||
@override
|
||||
String get quests => 'Quests';
|
||||
String get quests => 'クエスト';
|
||||
|
||||
@override
|
||||
String get traitName => 'Name';
|
||||
String get traitName => '名前';
|
||||
|
||||
@override
|
||||
String get traitRace => 'Race';
|
||||
String get traitRace => '種族';
|
||||
|
||||
@override
|
||||
String get traitClass => 'Class';
|
||||
String get traitClass => '職業';
|
||||
|
||||
@override
|
||||
String get traitLevel => 'Level';
|
||||
String get traitLevel => 'レベル';
|
||||
|
||||
@override
|
||||
String get statStr => 'STR';
|
||||
@@ -137,43 +137,43 @@ class L10nJa extends L10n {
|
||||
String get statCha => 'CHA';
|
||||
|
||||
@override
|
||||
String get statHpMax => 'HP Max';
|
||||
String get statHpMax => 'HP最大';
|
||||
|
||||
@override
|
||||
String get statMpMax => 'MP Max';
|
||||
String get statMpMax => 'MP最大';
|
||||
|
||||
@override
|
||||
String get equipWeapon => 'Weapon';
|
||||
String get equipWeapon => '武器';
|
||||
|
||||
@override
|
||||
String get equipShield => 'Shield';
|
||||
String get equipShield => '盾';
|
||||
|
||||
@override
|
||||
String get equipHelm => 'Helm';
|
||||
String get equipHelm => '兜';
|
||||
|
||||
@override
|
||||
String get equipHauberk => 'Hauberk';
|
||||
String get equipHauberk => '鎧';
|
||||
|
||||
@override
|
||||
String get equipBrassairts => 'Brassairts';
|
||||
String get equipBrassairts => '肩当て';
|
||||
|
||||
@override
|
||||
String get equipVambraces => 'Vambraces';
|
||||
String get equipVambraces => '腕甲';
|
||||
|
||||
@override
|
||||
String get equipGauntlets => 'Gauntlets';
|
||||
String get equipGauntlets => '篭手';
|
||||
|
||||
@override
|
||||
String get equipGambeson => 'Gambeson';
|
||||
String get equipGambeson => '防護服';
|
||||
|
||||
@override
|
||||
String get equipCuisses => 'Cuisses';
|
||||
String get equipCuisses => '腿当て';
|
||||
|
||||
@override
|
||||
String get equipGreaves => 'Greaves';
|
||||
String get equipGreaves => '脛当て';
|
||||
|
||||
@override
|
||||
String get equipSollerets => 'Sollerets';
|
||||
String get equipSollerets => '鉄靴';
|
||||
|
||||
@override
|
||||
String get gold => 'コイン';
|
||||
@@ -184,63 +184,63 @@ class L10nJa extends L10n {
|
||||
}
|
||||
|
||||
@override
|
||||
String get prologue => 'Prologue';
|
||||
String get prologue => 'プロローグ';
|
||||
|
||||
@override
|
||||
String actNumber(String number) {
|
||||
return 'Act $number';
|
||||
return '第$number幕';
|
||||
}
|
||||
|
||||
@override
|
||||
String get noActiveQuests => 'No active quests';
|
||||
String get noActiveQuests => '進行中のクエストなし';
|
||||
|
||||
@override
|
||||
String questNumber(int number) {
|
||||
return 'Quest #$number';
|
||||
return 'クエスト #$number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get welcomeMessage => 'ASCII NEVER DIEへようこそ!';
|
||||
|
||||
@override
|
||||
String get noSavedGames => 'No saved games found.';
|
||||
String get noSavedGames => 'セーブデータがありません。';
|
||||
|
||||
@override
|
||||
String loadError(String error) {
|
||||
return 'Failed to load save file: $error';
|
||||
return 'セーブファイルの読み込みに失敗しました: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get name => 'Name';
|
||||
String get name => '名前';
|
||||
|
||||
@override
|
||||
String get generateName => 'Generate Name';
|
||||
String get generateName => '名前を生成';
|
||||
|
||||
@override
|
||||
String get total => 'Total';
|
||||
String get total => '合計';
|
||||
|
||||
@override
|
||||
String get unroll => '元に戻す';
|
||||
|
||||
@override
|
||||
String get roll => 'Roll';
|
||||
String get roll => 'ロール';
|
||||
|
||||
@override
|
||||
String get race => 'Race';
|
||||
String get race => '種族';
|
||||
|
||||
@override
|
||||
String get classTitle => 'Class';
|
||||
String get classTitle => '職業';
|
||||
|
||||
@override
|
||||
String percentComplete(int percent) {
|
||||
return '$percent% complete';
|
||||
return '$percent% 完了';
|
||||
}
|
||||
|
||||
@override
|
||||
String get newCharacterTitle => 'ASCII NEVER DIE - New Character';
|
||||
String get newCharacterTitle => 'アスキー ネバー ダイ - 新規キャラクター';
|
||||
|
||||
@override
|
||||
String get soldButton => 'Sold!';
|
||||
String get soldButton => '決定!';
|
||||
|
||||
@override
|
||||
String get endingCongratulations => '★ おめでとうございます ★';
|
||||
@@ -299,55 +299,55 @@ class L10nJa extends L10n {
|
||||
String get endingHoldToSpeedUp => '長押しで高速スクロール';
|
||||
|
||||
@override
|
||||
String get menuTitle => 'MENU';
|
||||
String get menuTitle => 'メニュー';
|
||||
|
||||
@override
|
||||
String get optionsTitle => 'OPTIONS';
|
||||
String get optionsTitle => 'オプション';
|
||||
|
||||
@override
|
||||
String get soundTitle => 'SOUND';
|
||||
String get soundTitle => 'サウンド';
|
||||
|
||||
@override
|
||||
String get controlSection => 'CONTROL';
|
||||
String get controlSection => '操作';
|
||||
|
||||
@override
|
||||
String get infoSection => 'INFO';
|
||||
String get infoSection => '情報';
|
||||
|
||||
@override
|
||||
String get settingsSection => 'SETTINGS';
|
||||
String get settingsSection => '設定';
|
||||
|
||||
@override
|
||||
String get saveExitSection => 'SAVE / EXIT';
|
||||
String get saveExitSection => 'セーブ / 終了';
|
||||
|
||||
@override
|
||||
String get ok => 'OK';
|
||||
|
||||
@override
|
||||
String get rechargeButton => 'RECHARGE';
|
||||
String get rechargeButton => 'チャージ';
|
||||
|
||||
@override
|
||||
String get createButton => 'CREATE';
|
||||
String get createButton => '作成';
|
||||
|
||||
@override
|
||||
String get previewTitle => 'PREVIEW';
|
||||
String get previewTitle => 'プレビュー';
|
||||
|
||||
@override
|
||||
String get nameTitle => 'NAME';
|
||||
String get nameTitle => '名前';
|
||||
|
||||
@override
|
||||
String get statsTitle => 'STATS';
|
||||
String get statsTitle => '能力値';
|
||||
|
||||
@override
|
||||
String get raceTitle => 'RACE';
|
||||
String get raceTitle => '種族';
|
||||
|
||||
@override
|
||||
String get classSection => 'CLASS';
|
||||
String get classSection => '職業';
|
||||
|
||||
@override
|
||||
String get bgmLabel => 'BGM';
|
||||
|
||||
@override
|
||||
String get sfxLabel => 'SFX';
|
||||
String get sfxLabel => '効果音';
|
||||
|
||||
@override
|
||||
String get hpLabel => 'HP';
|
||||
@@ -359,103 +359,322 @@ class L10nJa extends L10n {
|
||||
String get expLabel => 'EXP';
|
||||
|
||||
@override
|
||||
String get notifyLevelUp => 'LEVEL UP!';
|
||||
String get notifyLevelUp => 'レベルアップ!';
|
||||
|
||||
@override
|
||||
String notifyLevel(int level) {
|
||||
return 'Level $level';
|
||||
return 'レベル $level';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notifyQuestComplete => 'QUEST COMPLETE!';
|
||||
String get notifyQuestComplete => 'クエスト完了!';
|
||||
|
||||
@override
|
||||
String get notifyPrologueComplete => 'PROLOGUE COMPLETE!';
|
||||
String get notifyPrologueComplete => 'プロローグ完了!';
|
||||
|
||||
@override
|
||||
String notifyActComplete(int number) {
|
||||
return 'ACT $number COMPLETE!';
|
||||
return '第$number幕 完了!';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notifyNewSpell => 'NEW SPELL!';
|
||||
String get notifyNewSpell => '新しいスキル!';
|
||||
|
||||
@override
|
||||
String get notifyNewEquipment => 'NEW EQUIPMENT!';
|
||||
String get notifyNewEquipment => '新しい装備!';
|
||||
|
||||
@override
|
||||
String get notifyBossDefeated => 'BOSS DEFEATED!';
|
||||
String get notifyBossDefeated => 'ボス撃破!';
|
||||
|
||||
@override
|
||||
String get rechargeRollsTitle => 'RECHARGE ROLLS';
|
||||
String get rechargeRollsTitle => 'ロール回数チャージ';
|
||||
|
||||
@override
|
||||
String get rechargeRollsFree => 'Recharge 5 rolls for free?';
|
||||
String get rechargeRollsFree => '無料で5回チャージしますか?';
|
||||
|
||||
@override
|
||||
String get rechargeRollsAd => 'Watch an ad to recharge 5 rolls?';
|
||||
String get rechargeRollsAd => '広告を見て5回チャージしますか?';
|
||||
|
||||
@override
|
||||
String get debugTitle => 'DEBUG';
|
||||
String get debugTitle => 'デバッグ';
|
||||
|
||||
@override
|
||||
String get debugCheatsTitle => 'DEBUG CHEATS';
|
||||
String get debugCheatsTitle => 'デバッグチート';
|
||||
|
||||
@override
|
||||
String get debugToolsTitle => 'DEBUG TOOLS';
|
||||
String get debugToolsTitle => 'デバッグツール';
|
||||
|
||||
@override
|
||||
String get debugDeveloperTools => 'DEVELOPER TOOLS';
|
||||
String get debugDeveloperTools => '開発者ツール';
|
||||
|
||||
@override
|
||||
String get debugSkipTask => 'SKIP TASK (L+1)';
|
||||
String get debugSkipTask => 'タスクスキップ (L+1)';
|
||||
|
||||
@override
|
||||
String get debugSkipTaskDesc => 'Complete task instantly';
|
||||
String get debugSkipTaskDesc => 'タスクを即時完了';
|
||||
|
||||
@override
|
||||
String get debugSkipQuest => 'SKIP QUEST (Q!)';
|
||||
String get debugSkipQuest => 'クエストスキップ (Q!)';
|
||||
|
||||
@override
|
||||
String get debugSkipQuestDesc => 'Complete quest instantly';
|
||||
String get debugSkipQuestDesc => 'クエストを即時完了';
|
||||
|
||||
@override
|
||||
String get debugSkipAct => 'SKIP ACT (P!)';
|
||||
String get debugSkipAct => 'アクトスキップ (P!)';
|
||||
|
||||
@override
|
||||
String get debugSkipActDesc => 'Complete act instantly';
|
||||
String get debugSkipActDesc => 'アクトを即時完了';
|
||||
|
||||
@override
|
||||
String get debugCreateTestCharacter => 'CREATE TEST CHARACTER';
|
||||
String get debugCreateTestCharacter => 'テストキャラクター作成';
|
||||
|
||||
@override
|
||||
String get debugCreateTestCharacterDesc =>
|
||||
'Register Level 100 character to Hall of Fame';
|
||||
String get debugCreateTestCharacterDesc => 'レベル100キャラクターを殿堂に登録';
|
||||
|
||||
@override
|
||||
String get debugCreateTestCharacterTitle => 'CREATE TEST CHARACTER?';
|
||||
String get debugCreateTestCharacterTitle => 'テストキャラクターを作成しますか?';
|
||||
|
||||
@override
|
||||
String get debugCreateTestCharacterMessage =>
|
||||
'Current character will be converted to Level 100\nand registered to the Hall of Fame.\n\n⚠️ Current save file will be deleted.\nThis action cannot be undone.';
|
||||
'現在のキャラクターがレベル100に変換され\n殿堂に登録されます。\n\n⚠️ 現在のセーブファイルは削除されます。\nこの操作は元に戻せません。';
|
||||
|
||||
@override
|
||||
String get debugTurbo => 'DEBUG: TURBO (20x)';
|
||||
String get debugTurbo => 'デバッグ: ターボ (20x)';
|
||||
|
||||
@override
|
||||
String get debugIapPurchased => 'IAP PURCHASED';
|
||||
String get debugIapPurchased => 'IAP購入済み';
|
||||
|
||||
@override
|
||||
String get debugIapPurchasedDesc => 'ON: Behave as paid user (ads removed)';
|
||||
String get debugIapPurchasedDesc => 'ON: 有料ユーザーとして動作(広告非表示)';
|
||||
|
||||
@override
|
||||
String get debugOfflineHours => 'OFFLINE HOURS';
|
||||
String get debugOfflineHours => 'オフライン時間';
|
||||
|
||||
@override
|
||||
String get debugOfflineHoursDesc =>
|
||||
'Test return rewards (applies on restart)';
|
||||
String get debugOfflineHoursDesc => '復帰報酬テスト(再起動時に適用)';
|
||||
|
||||
@override
|
||||
String get debugTestCharacterDesc =>
|
||||
'Modify current character to Level 100\nand register to the Hall of Fame.';
|
||||
String get debugTestCharacterDesc => '現在のキャラクターをレベル100に変更して\n殿堂に登録します。';
|
||||
|
||||
@override
|
||||
String get arenaTitle => 'ローカルアリーナ';
|
||||
|
||||
@override
|
||||
String get arenaSelectFighter => 'ファイターを選択';
|
||||
|
||||
@override
|
||||
String get arenaEmptyTitle => 'ヒーローが不足しています';
|
||||
|
||||
@override
|
||||
String get arenaEmptyHint => '2人以上のキャラでクリアしてください';
|
||||
|
||||
@override
|
||||
String get arenaSetupTitle => 'アリーナ設定';
|
||||
|
||||
@override
|
||||
String get arenaStartBattle => 'バトル開始';
|
||||
|
||||
@override
|
||||
String get arenaBattleTitle => 'アリーナバトル';
|
||||
|
||||
@override
|
||||
String get arenaMyEquipment => '自分の装備';
|
||||
|
||||
@override
|
||||
String get arenaEnemyEquipment => '敵の装備';
|
||||
|
||||
@override
|
||||
String get arenaSelected => '選択済み';
|
||||
|
||||
@override
|
||||
String get arenaRecommended => 'おすすめ';
|
||||
|
||||
@override
|
||||
String get arenaWeaponLocked => 'ロック';
|
||||
|
||||
@override
|
||||
String get arenaVictory => '勝利!';
|
||||
|
||||
@override
|
||||
String get arenaDefeat => '敗北...';
|
||||
|
||||
@override
|
||||
String get arenaEquipmentExchange => '装備交換';
|
||||
|
||||
@override
|
||||
String get arenaTurns => 'ターン';
|
||||
|
||||
@override
|
||||
String get arenaWinner => '勝者';
|
||||
|
||||
@override
|
||||
String get arenaLoser => '敗者';
|
||||
|
||||
@override
|
||||
String arenaDefeatedIn(String winner, String loser, int turns) {
|
||||
return '$winnerが$loserを$turnsターンで撃破';
|
||||
}
|
||||
|
||||
@override
|
||||
String arenaScoreGain(int score) {
|
||||
return '+$score獲得予定';
|
||||
}
|
||||
|
||||
@override
|
||||
String arenaScoreLose(int score) {
|
||||
return '$score損失予定';
|
||||
}
|
||||
|
||||
@override
|
||||
String get arenaEvenTrade => '等価交換';
|
||||
|
||||
@override
|
||||
String get arenaScore => 'スコア';
|
||||
|
||||
@override
|
||||
String get statsStatistics => '統計';
|
||||
|
||||
@override
|
||||
String get statsSession => 'セッション';
|
||||
|
||||
@override
|
||||
String get statsAccumulated => '累積';
|
||||
|
||||
@override
|
||||
String get statsCombat => '戦闘';
|
||||
|
||||
@override
|
||||
String get statsPlayTime => 'プレイ時間';
|
||||
|
||||
@override
|
||||
String get statsMonstersKilled => '倒したモンスター';
|
||||
|
||||
@override
|
||||
String get statsBossesDefeated => 'ボス討伐';
|
||||
|
||||
@override
|
||||
String get statsDeaths => '死亡回数';
|
||||
|
||||
@override
|
||||
String get statsDamage => 'ダメージ';
|
||||
|
||||
@override
|
||||
String get statsDamageDealt => '与えたダメージ';
|
||||
|
||||
@override
|
||||
String get statsDamageTaken => '受けたダメージ';
|
||||
|
||||
@override
|
||||
String get statsAverageDps => '平均DPS';
|
||||
|
||||
@override
|
||||
String get statsSkills => 'スキル';
|
||||
|
||||
@override
|
||||
String get statsSkillsUsed => 'スキル使用';
|
||||
|
||||
@override
|
||||
String get statsCriticalHits => 'クリティカルヒット';
|
||||
|
||||
@override
|
||||
String get statsMaxCriticalStreak => '最大連続クリティカル';
|
||||
|
||||
@override
|
||||
String get statsCriticalRate => 'クリティカル率';
|
||||
|
||||
@override
|
||||
String get statsEconomy => '経済';
|
||||
|
||||
@override
|
||||
String get statsGoldEarned => '獲得ゴールド';
|
||||
|
||||
@override
|
||||
String get statsGoldSpent => '消費ゴールド';
|
||||
|
||||
@override
|
||||
String get statsItemsSold => '売却アイテム';
|
||||
|
||||
@override
|
||||
String get statsPotionsUsed => 'ポーション使用';
|
||||
|
||||
@override
|
||||
String get statsProgress => '進行';
|
||||
|
||||
@override
|
||||
String get statsLevelUps => 'レベルアップ';
|
||||
|
||||
@override
|
||||
String get statsQuestsCompleted => '完了したクエスト';
|
||||
|
||||
@override
|
||||
String get statsRecords => '記録';
|
||||
|
||||
@override
|
||||
String get statsHighestLevel => '最高レベル';
|
||||
|
||||
@override
|
||||
String get statsHighestGoldHeld => '最大所持ゴールド';
|
||||
|
||||
@override
|
||||
String get statsBestCriticalStreak => '最高連続クリティカル';
|
||||
|
||||
@override
|
||||
String get statsTotalPlay => '総プレイ';
|
||||
|
||||
@override
|
||||
String get statsTotalPlayTime => '総プレイ時間';
|
||||
|
||||
@override
|
||||
String get statsGamesStarted => '開始したゲーム';
|
||||
|
||||
@override
|
||||
String get statsGamesCompleted => 'クリアしたゲーム';
|
||||
|
||||
@override
|
||||
String get statsCompletionRate => 'クリア率';
|
||||
|
||||
@override
|
||||
String get statsTotalCombat => '総戦闘';
|
||||
|
||||
@override
|
||||
String get statsTotalDeaths => '総死亡';
|
||||
|
||||
@override
|
||||
String get statsTotalLevelUps => '総レベルアップ';
|
||||
|
||||
@override
|
||||
String get statsTotalDamage => '総ダメージ';
|
||||
|
||||
@override
|
||||
String get statsTotalSkills => '総スキル';
|
||||
|
||||
@override
|
||||
String get statsTotalEconomy => '総経済';
|
||||
|
||||
@override
|
||||
String get notifyLevelUpLabel => 'レベルアップ';
|
||||
|
||||
@override
|
||||
String get notifyQuestDoneLabel => 'クエスト完了';
|
||||
|
||||
@override
|
||||
String get notifyActClearLabel => '幕完了';
|
||||
|
||||
@override
|
||||
String get notifyNewSpellLabel => '新しいスキル';
|
||||
|
||||
@override
|
||||
String get notifyNewItemLabel => '新しいアイテム';
|
||||
|
||||
@override
|
||||
String get notifyBossSlainLabel => 'ボス撃破';
|
||||
|
||||
@override
|
||||
String get notifySavedLabel => 'セーブ済み';
|
||||
|
||||
@override
|
||||
String get notifyInfoLabel => '情報';
|
||||
|
||||
@override
|
||||
String get notifyWarningLabel => '警告';
|
||||
}
|
||||
|
||||
@@ -455,4 +455,226 @@ class L10nKo extends L10n {
|
||||
|
||||
@override
|
||||
String get debugTestCharacterDesc => '현재 캐릭터를 레벨 100으로 수정하여\n명예의 전당에 등록합니다.';
|
||||
|
||||
@override
|
||||
String get arenaTitle => '로컬 아레나';
|
||||
|
||||
@override
|
||||
String get arenaSelectFighter => '전사를 선택하세요';
|
||||
|
||||
@override
|
||||
String get arenaEmptyTitle => '영웅이 부족합니다';
|
||||
|
||||
@override
|
||||
String get arenaEmptyHint => '2명 이상 캐릭터로 클리어하세요';
|
||||
|
||||
@override
|
||||
String get arenaSetupTitle => '아레나 설정';
|
||||
|
||||
@override
|
||||
String get arenaStartBattle => '전투 시작';
|
||||
|
||||
@override
|
||||
String get arenaBattleTitle => '아레나 전투';
|
||||
|
||||
@override
|
||||
String get arenaMyEquipment => '내 장비';
|
||||
|
||||
@override
|
||||
String get arenaEnemyEquipment => '상대 장비';
|
||||
|
||||
@override
|
||||
String get arenaSelected => '선택됨';
|
||||
|
||||
@override
|
||||
String get arenaRecommended => '추천';
|
||||
|
||||
@override
|
||||
String get arenaWeaponLocked => '잠김';
|
||||
|
||||
@override
|
||||
String get arenaVictory => '승리!';
|
||||
|
||||
@override
|
||||
String get arenaDefeat => '패배...';
|
||||
|
||||
@override
|
||||
String get arenaEquipmentExchange => '장비 교환';
|
||||
|
||||
@override
|
||||
String get arenaTurns => '턴';
|
||||
|
||||
@override
|
||||
String get arenaWinner => '승자';
|
||||
|
||||
@override
|
||||
String get arenaLoser => '패자';
|
||||
|
||||
@override
|
||||
String arenaDefeatedIn(String winner, String loser, int turns) {
|
||||
return '$winner이(가) $loser을(를) $turns턴 만에 격파';
|
||||
}
|
||||
|
||||
@override
|
||||
String arenaScoreGain(int score) {
|
||||
return '+$score 획득 예정';
|
||||
}
|
||||
|
||||
@override
|
||||
String arenaScoreLose(int score) {
|
||||
return '$score 손실 예정';
|
||||
}
|
||||
|
||||
@override
|
||||
String get arenaEvenTrade => '등가 교환';
|
||||
|
||||
@override
|
||||
String get arenaScore => '점수';
|
||||
|
||||
@override
|
||||
String get statsStatistics => '통계';
|
||||
|
||||
@override
|
||||
String get statsSession => '세션';
|
||||
|
||||
@override
|
||||
String get statsAccumulated => '누적';
|
||||
|
||||
@override
|
||||
String get statsCombat => '전투';
|
||||
|
||||
@override
|
||||
String get statsPlayTime => '플레이 시간';
|
||||
|
||||
@override
|
||||
String get statsMonstersKilled => '처치한 몬스터';
|
||||
|
||||
@override
|
||||
String get statsBossesDefeated => '보스 처치';
|
||||
|
||||
@override
|
||||
String get statsDeaths => '사망 횟수';
|
||||
|
||||
@override
|
||||
String get statsDamage => '데미지';
|
||||
|
||||
@override
|
||||
String get statsDamageDealt => '입힌 데미지';
|
||||
|
||||
@override
|
||||
String get statsDamageTaken => '받은 데미지';
|
||||
|
||||
@override
|
||||
String get statsAverageDps => '평균 DPS';
|
||||
|
||||
@override
|
||||
String get statsSkills => '스킬';
|
||||
|
||||
@override
|
||||
String get statsSkillsUsed => '스킬 사용';
|
||||
|
||||
@override
|
||||
String get statsCriticalHits => '크리티컬 히트';
|
||||
|
||||
@override
|
||||
String get statsMaxCriticalStreak => '최대 연속 크리티컬';
|
||||
|
||||
@override
|
||||
String get statsCriticalRate => '크리티컬 비율';
|
||||
|
||||
@override
|
||||
String get statsEconomy => '경제';
|
||||
|
||||
@override
|
||||
String get statsGoldEarned => '획득 골드';
|
||||
|
||||
@override
|
||||
String get statsGoldSpent => '소비 골드';
|
||||
|
||||
@override
|
||||
String get statsItemsSold => '판매 아이템';
|
||||
|
||||
@override
|
||||
String get statsPotionsUsed => '물약 사용';
|
||||
|
||||
@override
|
||||
String get statsProgress => '진행';
|
||||
|
||||
@override
|
||||
String get statsLevelUps => '레벨업';
|
||||
|
||||
@override
|
||||
String get statsQuestsCompleted => '완료한 퀘스트';
|
||||
|
||||
@override
|
||||
String get statsRecords => '기록';
|
||||
|
||||
@override
|
||||
String get statsHighestLevel => '최고 레벨';
|
||||
|
||||
@override
|
||||
String get statsHighestGoldHeld => '최대 보유 골드';
|
||||
|
||||
@override
|
||||
String get statsBestCriticalStreak => '최고 연속 크리티컬';
|
||||
|
||||
@override
|
||||
String get statsTotalPlay => '총 플레이';
|
||||
|
||||
@override
|
||||
String get statsTotalPlayTime => '총 플레이 시간';
|
||||
|
||||
@override
|
||||
String get statsGamesStarted => '시작한 게임';
|
||||
|
||||
@override
|
||||
String get statsGamesCompleted => '클리어한 게임';
|
||||
|
||||
@override
|
||||
String get statsCompletionRate => '클리어율';
|
||||
|
||||
@override
|
||||
String get statsTotalCombat => '총 전투';
|
||||
|
||||
@override
|
||||
String get statsTotalDeaths => '총 사망';
|
||||
|
||||
@override
|
||||
String get statsTotalLevelUps => '총 레벨업';
|
||||
|
||||
@override
|
||||
String get statsTotalDamage => '총 데미지';
|
||||
|
||||
@override
|
||||
String get statsTotalSkills => '총 스킬';
|
||||
|
||||
@override
|
||||
String get statsTotalEconomy => '총 경제';
|
||||
|
||||
@override
|
||||
String get notifyLevelUpLabel => '레벨 업';
|
||||
|
||||
@override
|
||||
String get notifyQuestDoneLabel => '퀘스트 완료';
|
||||
|
||||
@override
|
||||
String get notifyActClearLabel => '막 완료';
|
||||
|
||||
@override
|
||||
String get notifyNewSpellLabel => '새 주문';
|
||||
|
||||
@override
|
||||
String get notifyNewItemLabel => '새 아이템';
|
||||
|
||||
@override
|
||||
String get notifyBossSlainLabel => '보스 처치';
|
||||
|
||||
@override
|
||||
String get notifySavedLabel => '저장됨';
|
||||
|
||||
@override
|
||||
String get notifyInfoLabel => '정보';
|
||||
|
||||
@override
|
||||
String get notifyWarningLabel => '경고';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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';
|
||||
|
||||
void main() {
|
||||
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/l10n/app_localizations.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/iap_service.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
import 'package:asciineverdie/src/core/infrastructure/iap_service.dart';
|
||||
import 'package:asciineverdie/src/app_theme.dart';
|
||||
import 'package:asciineverdie/src/splash_screen.dart';
|
||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
||||
@@ -221,140 +222,6 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp>
|
||||
}
|
||||
}
|
||||
|
||||
/// 앱 테마 (Dark Fantasy 스타일)
|
||||
ThemeData get _theme => ThemeData(
|
||||
colorScheme: RetroColors.darkColorScheme,
|
||||
scaffoldBackgroundColor: RetroColors.deepBrown,
|
||||
useMaterial3: true,
|
||||
// 카드/다이얼로그 레트로 배경
|
||||
cardColor: RetroColors.darkBrown,
|
||||
dialogTheme: const DialogThemeData(
|
||||
backgroundColor: Color(0xFF24283B),
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 15,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
),
|
||||
// 앱바 레트로 스타일
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Color(0xFF24283B),
|
||||
foregroundColor: Color(0xFFC0CAF5),
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 15,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
),
|
||||
// 버튼 테마 (inherit: false로 애니메이션 lerp 오류 방지)
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF3D4260),
|
||||
foregroundColor: const Color(0xFFC0CAF5),
|
||||
textStyle: const TextStyle(
|
||||
inherit: false,
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFE0AF68),
|
||||
side: const BorderSide(color: Color(0xFFE0AF68), width: 2),
|
||||
textStyle: const TextStyle(
|
||||
inherit: false,
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFC0CAF5),
|
||||
textStyle: const TextStyle(
|
||||
inherit: false,
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 텍스트 테마
|
||||
textTheme: const TextTheme(
|
||||
headlineLarge: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 20,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
headlineMedium: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 16,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
headlineSmall: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 15,
|
||||
color: Color(0xFFE0AF68),
|
||||
),
|
||||
titleLarge: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 15,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
titleSmall: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
bodyLarge: TextStyle(fontSize: 18, color: Color(0xFFC0CAF5)),
|
||||
bodyMedium: TextStyle(fontSize: 17, color: Color(0xFFC0CAF5)),
|
||||
bodySmall: TextStyle(fontSize: 15, color: Color(0xFFC0CAF5)),
|
||||
labelLarge: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
labelMedium: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
labelSmall: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 13,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
),
|
||||
// 칩 테마
|
||||
chipTheme: const ChipThemeData(
|
||||
backgroundColor: Color(0xFF2A2E3F),
|
||||
labelStyle: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
side: BorderSide(color: Color(0xFF545C7E)),
|
||||
),
|
||||
// 리스트 타일 테마
|
||||
listTileTheme: const ListTileThemeData(
|
||||
textColor: Color(0xFFC0CAF5),
|
||||
iconColor: Color(0xFFE0AF68),
|
||||
),
|
||||
// 프로그레스 인디케이터
|
||||
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
||||
color: Color(0xFFE0AF68),
|
||||
linearTrackColor: Color(0xFF3B4261),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
@@ -363,7 +230,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp>
|
||||
localizationsDelegates: L10n.localizationsDelegates,
|
||||
supportedLocales: L10n.supportedLocales,
|
||||
locale: _locale, // 사용자 선택 로케일 (null이면 시스템 기본값)
|
||||
theme: _theme,
|
||||
theme: buildAppTheme(),
|
||||
navigatorObservers: [_routeObserver],
|
||||
builder: (context, child) {
|
||||
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
|
||||
@@ -382,7 +249,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp>
|
||||
Widget _buildHomeScreen() {
|
||||
// 세이브 확인 중이면 로딩 스플래시 표시
|
||||
if (_isCheckingSave) {
|
||||
return const _SplashScreen();
|
||||
return const SplashScreen();
|
||||
}
|
||||
|
||||
return FrontScreen(
|
||||
@@ -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 {
|
||||
await _staticBgmPlayer?.stop();
|
||||
} catch (_) {}
|
||||
// SFX도 즉시 정지
|
||||
await _playerSfxPool?.stopAll();
|
||||
await _monsterSfxPool?.stopAll();
|
||||
_currentBgm = null;
|
||||
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 {
|
||||
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 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/core/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
|
||||
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/item_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// 아레나 서비스
|
||||
@@ -23,64 +20,6 @@ class ArenaService {
|
||||
|
||||
final DeterministicRandom _rng;
|
||||
|
||||
late final SkillService _skillService = SkillService(rng: _rng);
|
||||
|
||||
// ============================================================================
|
||||
// 스킬 시스템 헬퍼
|
||||
// ============================================================================
|
||||
|
||||
/// HallOfFameEntry의 finalSkills에서 Skill 목록 추출
|
||||
List<Skill> _getSkillsFromEntry(HallOfFameEntry entry) {
|
||||
final skillData = entry.finalSkills;
|
||||
if (skillData == null || skillData.isEmpty) return [];
|
||||
|
||||
final skills = <Skill>[];
|
||||
for (final data in skillData) {
|
||||
final skillName = data['name'];
|
||||
if (skillName != null) {
|
||||
final skill = SkillData.getSkillBySpellName(skillName);
|
||||
if (skill != null) {
|
||||
skills.add(skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
/// 스킬 ID 목록 추출 (HallOfFameEntry에서)
|
||||
List<String> _getSkillIdsFromEntry(HallOfFameEntry entry) {
|
||||
return _getSkillsFromEntry(entry).map((s) => s.id).toList();
|
||||
}
|
||||
|
||||
/// 스킬 랭크 조회 (HallOfFameEntry의 finalSkills에서)
|
||||
int _getSkillRankFromEntry(HallOfFameEntry entry, String skillId) {
|
||||
final skill = SkillData.getSkillById(skillId);
|
||||
if (skill == null) return 1;
|
||||
|
||||
final skillData = entry.finalSkills;
|
||||
if (skillData == null || skillData.isEmpty) return 1;
|
||||
|
||||
for (final data in skillData) {
|
||||
if (data['name'] == skill.name) {
|
||||
final rankStr = data['rank'] ?? 'I';
|
||||
return _romanToInt(rankStr);
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// 로마 숫자 → 정수 변환
|
||||
int _romanToInt(String roman) {
|
||||
return switch (roman) {
|
||||
'I' => 1,
|
||||
'II' => 2,
|
||||
'III' => 3,
|
||||
'IV' => 4,
|
||||
'V' => 5,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 상대 결정
|
||||
// ============================================================================
|
||||
@@ -230,452 +169,10 @@ class ArenaService {
|
||||
|
||||
/// 전투 시뮬레이션 (애니메이션용 스트림)
|
||||
///
|
||||
/// progress_service._processCombatTickWithSkills()와 동일한 로직 사용
|
||||
/// [match] 대전 정보
|
||||
/// Returns: 턴별 전투 상황 스트림
|
||||
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) async* {
|
||||
final calculator = CombatCalculator(rng: _rng);
|
||||
|
||||
final challengerStats = match.challenger.finalStats;
|
||||
final opponentStats = match.opponent.finalStats;
|
||||
|
||||
if (challengerStats == null || opponentStats == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 스킬 ID 목록 로드 (SkillBook과 동일한 방식)
|
||||
var challengerSkillIds = _getSkillIdsFromEntry(match.challenger);
|
||||
var opponentSkillIds = _getSkillIdsFromEntry(match.opponent);
|
||||
|
||||
// 스킬이 없으면 기본 스킬 사용
|
||||
if (challengerSkillIds.isEmpty) {
|
||||
challengerSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
if (opponentSkillIds.isEmpty) {
|
||||
opponentSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
|
||||
// 스킬 시스템 상태 초기화
|
||||
var challengerSkillSystem = SkillSystemState.empty();
|
||||
var opponentSkillSystem = SkillSystemState.empty();
|
||||
|
||||
// DOT 및 디버프 추적 (일반 전투와 동일)
|
||||
var challengerDoTs = <DotEffect>[];
|
||||
var opponentDoTs = <DotEffect>[];
|
||||
var challengerDebuffs = <ActiveBuff>[];
|
||||
var opponentDebuffs = <ActiveBuff>[];
|
||||
|
||||
var playerCombatStats = challengerStats.copyWith(
|
||||
hpCurrent: challengerStats.hpMax,
|
||||
mpCurrent: challengerStats.mpMax,
|
||||
);
|
||||
|
||||
var opponentCombatStats = opponentStats.copyWith(
|
||||
hpCurrent: opponentStats.hpMax,
|
||||
mpCurrent: opponentStats.mpMax,
|
||||
);
|
||||
|
||||
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
opponentCombatStats,
|
||||
match.opponent.characterName,
|
||||
);
|
||||
|
||||
var challengerMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
playerCombatStats,
|
||||
match.challenger.characterName,
|
||||
);
|
||||
|
||||
int playerAccum = 0;
|
||||
int opponentAccum = 0;
|
||||
int elapsedMs = 0;
|
||||
const tickMs = 200;
|
||||
int turns = 0;
|
||||
|
||||
// 초기 상태 전송
|
||||
yield ArenaCombatTurn(
|
||||
challengerHp: playerCombatStats.hpCurrent,
|
||||
opponentHp: opponentCombatStats.hpCurrent,
|
||||
challengerHpMax: playerCombatStats.hpMax,
|
||||
opponentHpMax: opponentCombatStats.hpMax,
|
||||
challengerMp: playerCombatStats.mpCurrent,
|
||||
opponentMp: opponentCombatStats.mpCurrent,
|
||||
challengerMpMax: playerCombatStats.mpMax,
|
||||
opponentMpMax: opponentCombatStats.mpMax,
|
||||
);
|
||||
|
||||
while (playerCombatStats.isAlive && opponentCombatStats.hpCurrent > 0) {
|
||||
playerAccum += tickMs;
|
||||
opponentAccum += tickMs;
|
||||
elapsedMs += tickMs;
|
||||
|
||||
// 스킬 시스템 시간 업데이트
|
||||
challengerSkillSystem = challengerSkillSystem.copyWith(
|
||||
elapsedMs: elapsedMs,
|
||||
);
|
||||
opponentSkillSystem = opponentSkillSystem.copyWith(elapsedMs: elapsedMs);
|
||||
|
||||
int? challengerDamage;
|
||||
int? opponentDamage;
|
||||
bool isChallengerCritical = false;
|
||||
bool isOpponentCritical = false;
|
||||
bool isChallengerEvaded = false;
|
||||
bool isOpponentEvaded = false;
|
||||
bool isChallengerBlocked = false;
|
||||
bool isOpponentBlocked = false;
|
||||
String? challengerSkillUsed;
|
||||
String? opponentSkillUsed;
|
||||
int? challengerHealAmount;
|
||||
int? opponentHealAmount;
|
||||
|
||||
// =========================================================================
|
||||
// DOT 틱 처리 (도전자 → 상대에게 적용된 DOT)
|
||||
// =========================================================================
|
||||
var dotDamageToOpponent = 0;
|
||||
final updatedChallengerDoTs = <DotEffect>[];
|
||||
for (final dot in challengerDoTs) {
|
||||
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
||||
if (ticksTriggered > 0) {
|
||||
dotDamageToOpponent += dot.damagePerTick * ticksTriggered;
|
||||
}
|
||||
if (updatedDot.isActive) {
|
||||
updatedChallengerDoTs.add(updatedDot);
|
||||
}
|
||||
}
|
||||
challengerDoTs = updatedChallengerDoTs;
|
||||
|
||||
if (dotDamageToOpponent > 0 && opponentCombatStats.hpCurrent > 0) {
|
||||
opponentCombatStats = opponentCombatStats.copyWith(
|
||||
hpCurrent: (opponentCombatStats.hpCurrent - dotDamageToOpponent)
|
||||
.clamp(0, opponentCombatStats.hpMax),
|
||||
);
|
||||
}
|
||||
|
||||
// DOT 틱 처리 (상대 → 도전자에게 적용된 DOT)
|
||||
var dotDamageToChallenger = 0;
|
||||
final updatedOpponentDoTs = <DotEffect>[];
|
||||
for (final dot in opponentDoTs) {
|
||||
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
|
||||
if (ticksTriggered > 0) {
|
||||
dotDamageToChallenger += dot.damagePerTick * ticksTriggered;
|
||||
}
|
||||
if (updatedDot.isActive) {
|
||||
updatedOpponentDoTs.add(updatedDot);
|
||||
}
|
||||
}
|
||||
opponentDoTs = updatedOpponentDoTs;
|
||||
|
||||
if (dotDamageToChallenger > 0 && playerCombatStats.isAlive) {
|
||||
playerCombatStats = playerCombatStats.copyWith(
|
||||
hpCurrent: (playerCombatStats.hpCurrent - dotDamageToChallenger)
|
||||
.clamp(0, playerCombatStats.hpMax),
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 만료된 디버프 정리
|
||||
// =========================================================================
|
||||
challengerDebuffs = challengerDebuffs
|
||||
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
||||
.toList();
|
||||
opponentDebuffs = opponentDebuffs
|
||||
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
|
||||
.toList();
|
||||
|
||||
// =========================================================================
|
||||
// 도전자 턴 (selectAutoSkill 사용 - 일반 전투와 동일)
|
||||
// =========================================================================
|
||||
if (playerAccum >= playerCombatStats.attackDelayMs) {
|
||||
playerAccum = 0;
|
||||
|
||||
// 상대 몬스터 스탯 동기화
|
||||
opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
opponentCombatStats,
|
||||
match.opponent.characterName,
|
||||
);
|
||||
|
||||
// 스킬 자동 선택 (progress_service와 동일한 로직)
|
||||
final selectedSkill = _skillService.selectAutoSkill(
|
||||
player: playerCombatStats,
|
||||
monster: opponentMonsterStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
availableSkillIds: challengerSkillIds,
|
||||
activeDoTs: challengerDoTs,
|
||||
activeDebuffs: opponentDebuffs,
|
||||
);
|
||||
|
||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||
// 스킬 랭크 조회 및 적용
|
||||
final skillRank = _getSkillRankFromEntry(
|
||||
match.challenger,
|
||||
selectedSkill.id,
|
||||
);
|
||||
final skillResult = _skillService.useAttackSkillWithRank(
|
||||
skill: selectedSkill,
|
||||
player: playerCombatStats,
|
||||
monster: opponentMonsterStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
rank: skillRank,
|
||||
);
|
||||
playerCombatStats = skillResult.updatedPlayer;
|
||||
opponentCombatStats = opponentCombatStats.copyWith(
|
||||
hpCurrent: skillResult.updatedMonster.hpCurrent,
|
||||
);
|
||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||
challengerSkillUsed = selectedSkill.name;
|
||||
challengerDamage = skillResult.result.damage;
|
||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||
// DOT 스킬 사용
|
||||
final skillResult = _skillService.useDotSkill(
|
||||
skill: selectedSkill,
|
||||
player: playerCombatStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
playerInt: playerCombatStats.atk ~/ 10,
|
||||
playerWis: playerCombatStats.def ~/ 10,
|
||||
);
|
||||
playerCombatStats = skillResult.updatedPlayer;
|
||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||
if (skillResult.dotEffect != null) {
|
||||
challengerDoTs.add(skillResult.dotEffect!);
|
||||
}
|
||||
challengerSkillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||
// 회복 스킬 사용
|
||||
final skillResult = _skillService.useHealSkill(
|
||||
skill: selectedSkill,
|
||||
player: playerCombatStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
);
|
||||
playerCombatStats = skillResult.updatedPlayer;
|
||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||
challengerSkillUsed = selectedSkill.name;
|
||||
challengerHealAmount = skillResult.result.healedAmount;
|
||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||
// 버프 스킬 사용
|
||||
final skillResult = _skillService.useBuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: playerCombatStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
);
|
||||
playerCombatStats = skillResult.updatedPlayer;
|
||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||
challengerSkillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||
// 디버프 스킬 사용
|
||||
final skillResult = _skillService.useDebuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: playerCombatStats,
|
||||
skillSystem: challengerSkillSystem,
|
||||
currentDebuffs: opponentDebuffs,
|
||||
);
|
||||
playerCombatStats = skillResult.updatedPlayer;
|
||||
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||
final debuffEffect = skillResult.debuffEffect;
|
||||
if (debuffEffect != null) {
|
||||
opponentDebuffs =
|
||||
opponentDebuffs
|
||||
.where(
|
||||
(ActiveBuff d) => d.effect.id != debuffEffect.effect.id,
|
||||
)
|
||||
.toList()
|
||||
..add(debuffEffect);
|
||||
}
|
||||
challengerSkillUsed = selectedSkill.name;
|
||||
} else {
|
||||
// 일반 공격
|
||||
final result = calculator.playerAttackMonster(
|
||||
attacker: playerCombatStats,
|
||||
defender: opponentMonsterStats,
|
||||
);
|
||||
opponentCombatStats = opponentCombatStats.copyWith(
|
||||
hpCurrent: result.updatedDefender.hpCurrent,
|
||||
);
|
||||
|
||||
if (result.result.isHit) {
|
||||
challengerDamage = result.result.damage;
|
||||
isChallengerCritical = result.result.isCritical;
|
||||
} else {
|
||||
isOpponentEvaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 상대 턴 (selectAutoSkill 사용 - 일반 전투와 동일)
|
||||
// =========================================================================
|
||||
if (opponentCombatStats.hpCurrent > 0 &&
|
||||
opponentAccum >= opponentCombatStats.attackDelayMs) {
|
||||
opponentAccum = 0;
|
||||
|
||||
// 도전자 몬스터 스탯 동기화
|
||||
challengerMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
playerCombatStats,
|
||||
match.challenger.characterName,
|
||||
);
|
||||
|
||||
// 스킬 자동 선택 (progress_service와 동일한 로직)
|
||||
final selectedSkill = _skillService.selectAutoSkill(
|
||||
player: opponentCombatStats,
|
||||
monster: challengerMonsterStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
availableSkillIds: opponentSkillIds,
|
||||
activeDoTs: opponentDoTs,
|
||||
activeDebuffs: challengerDebuffs,
|
||||
);
|
||||
|
||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||
// 스킬 랭크 조회 및 적용
|
||||
final skillRank = _getSkillRankFromEntry(
|
||||
match.opponent,
|
||||
selectedSkill.id,
|
||||
);
|
||||
final skillResult = _skillService.useAttackSkillWithRank(
|
||||
skill: selectedSkill,
|
||||
player: opponentCombatStats,
|
||||
monster: challengerMonsterStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
rank: skillRank,
|
||||
);
|
||||
opponentCombatStats = skillResult.updatedPlayer;
|
||||
playerCombatStats = playerCombatStats.copyWith(
|
||||
hpCurrent: skillResult.updatedMonster.hpCurrent,
|
||||
);
|
||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||
opponentSkillUsed = selectedSkill.name;
|
||||
opponentDamage = skillResult.result.damage;
|
||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||
// DOT 스킬 사용
|
||||
final skillResult = _skillService.useDotSkill(
|
||||
skill: selectedSkill,
|
||||
player: opponentCombatStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
playerInt: opponentCombatStats.atk ~/ 10,
|
||||
playerWis: opponentCombatStats.def ~/ 10,
|
||||
);
|
||||
opponentCombatStats = skillResult.updatedPlayer;
|
||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||
if (skillResult.dotEffect != null) {
|
||||
opponentDoTs.add(skillResult.dotEffect!);
|
||||
}
|
||||
opponentSkillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||
// 회복 스킬 사용
|
||||
final skillResult = _skillService.useHealSkill(
|
||||
skill: selectedSkill,
|
||||
player: opponentCombatStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
);
|
||||
opponentCombatStats = skillResult.updatedPlayer;
|
||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||
opponentSkillUsed = selectedSkill.name;
|
||||
opponentHealAmount = skillResult.result.healedAmount;
|
||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||
// 버프 스킬 사용
|
||||
final skillResult = _skillService.useBuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: opponentCombatStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
);
|
||||
opponentCombatStats = skillResult.updatedPlayer;
|
||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||
opponentSkillUsed = selectedSkill.name;
|
||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||
// 디버프 스킬 사용
|
||||
final skillResult = _skillService.useDebuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: opponentCombatStats,
|
||||
skillSystem: opponentSkillSystem,
|
||||
currentDebuffs: challengerDebuffs,
|
||||
);
|
||||
opponentCombatStats = skillResult.updatedPlayer;
|
||||
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||
final debuffEffect = skillResult.debuffEffect;
|
||||
if (debuffEffect != null) {
|
||||
challengerDebuffs =
|
||||
challengerDebuffs
|
||||
.where(
|
||||
(ActiveBuff d) => d.effect.id != debuffEffect.effect.id,
|
||||
)
|
||||
.toList()
|
||||
..add(debuffEffect);
|
||||
}
|
||||
opponentSkillUsed = selectedSkill.name;
|
||||
} else {
|
||||
// 일반 공격 (디버프 효과 적용)
|
||||
var debuffedOpponent = opponentCombatStats;
|
||||
if (challengerDebuffs.isNotEmpty) {
|
||||
double atkMod = 0;
|
||||
for (final debuff in challengerDebuffs) {
|
||||
if (!debuff.isExpired(elapsedMs)) {
|
||||
atkMod += debuff.effect.atkModifier;
|
||||
}
|
||||
}
|
||||
final newAtk = (opponentCombatStats.atk * (1 + atkMod))
|
||||
.round()
|
||||
.clamp(opponentCombatStats.atk ~/ 10, opponentCombatStats.atk);
|
||||
debuffedOpponent = opponentCombatStats.copyWith(atk: newAtk);
|
||||
}
|
||||
|
||||
opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
debuffedOpponent,
|
||||
match.opponent.characterName,
|
||||
);
|
||||
final result = calculator.monsterAttackPlayer(
|
||||
attacker: opponentMonsterStats,
|
||||
defender: playerCombatStats,
|
||||
);
|
||||
playerCombatStats = result.updatedDefender;
|
||||
|
||||
if (result.result.isHit) {
|
||||
opponentDamage = result.result.damage;
|
||||
isOpponentCritical = result.result.isCritical;
|
||||
isChallengerBlocked = result.result.isBlocked;
|
||||
} else {
|
||||
isChallengerEvaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 액션이 발생했을 때만 턴 전송
|
||||
final hasAction =
|
||||
challengerDamage != null ||
|
||||
opponentDamage != null ||
|
||||
challengerHealAmount != null ||
|
||||
opponentHealAmount != null ||
|
||||
challengerSkillUsed != null ||
|
||||
opponentSkillUsed != null;
|
||||
|
||||
if (hasAction) {
|
||||
turns++;
|
||||
yield ArenaCombatTurn(
|
||||
challengerDamage: challengerDamage,
|
||||
opponentDamage: opponentDamage,
|
||||
challengerHp: playerCombatStats.hpCurrent,
|
||||
opponentHp: opponentCombatStats.hpCurrent,
|
||||
challengerHpMax: playerCombatStats.hpMax,
|
||||
opponentHpMax: opponentCombatStats.hpMax,
|
||||
challengerMp: playerCombatStats.mpCurrent,
|
||||
opponentMp: opponentCombatStats.mpCurrent,
|
||||
challengerMpMax: playerCombatStats.mpMax,
|
||||
opponentMpMax: opponentCombatStats.mpMax,
|
||||
isChallengerCritical: isChallengerCritical,
|
||||
isOpponentCritical: isOpponentCritical,
|
||||
isChallengerEvaded: isChallengerEvaded,
|
||||
isOpponentEvaded: isOpponentEvaded,
|
||||
isChallengerBlocked: isChallengerBlocked,
|
||||
isOpponentBlocked: isOpponentBlocked,
|
||||
challengerSkillUsed: challengerSkillUsed,
|
||||
opponentSkillUsed: opponentSkillUsed,
|
||||
challengerHealAmount: challengerHealAmount,
|
||||
opponentHealAmount: opponentHealAmount,
|
||||
);
|
||||
|
||||
// 애니메이션을 위한 딜레이
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
// 무한 루프 방지
|
||||
if (turns > 1000) break;
|
||||
}
|
||||
/// ArenaCombatSimulator에 위임하여 턴별 전투 상황을 스트림으로 반환.
|
||||
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) {
|
||||
final simulator = ArenaCombatSimulator(rng: _rng);
|
||||
return simulator.simulateCombat(match);
|
||||
}
|
||||
// ============================================================================
|
||||
// AI 베팅 슬롯 선택
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/engine/ad_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
||||
import 'package:asciineverdie/src/core/infrastructure/ad_service.dart';
|
||||
import 'package:asciineverdie/src/core/infrastructure/iap_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
|
||||
/// 캐릭터 생성 굴리기/되돌리기 서비스
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:asciineverdie/data/class_data.dart';
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/model/class_traits.dart';
|
||||
import 'package:asciineverdie/src/core/engine/player_attack_processor.dart';
|
||||
import 'package:asciineverdie/src/core/engine/potion_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||
@@ -124,11 +125,22 @@ class CombatTickService {
|
||||
newEvents.addAll(potionResult.events);
|
||||
}
|
||||
|
||||
// 스킬 버프 모디파이어 조회 (전투 계산에 적용)
|
||||
final buffMods = updatedSkillSystem.totalBuffModifiers;
|
||||
|
||||
// 플레이어 공격 체크
|
||||
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,
|
||||
playerStats: playerStats,
|
||||
playerStats: buffedPlayerForAttack,
|
||||
monsterStats: monsterStats,
|
||||
updatedSkillSystem: updatedSkillSystem,
|
||||
activeDoTs: activeDoTs,
|
||||
@@ -143,7 +155,14 @@ class CombatTickService {
|
||||
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;
|
||||
updatedSkillSystem = attackResult.skillSystem;
|
||||
activeDoTs = attackResult.activeDoTs;
|
||||
@@ -159,8 +178,20 @@ class CombatTickService {
|
||||
// 몬스터가 살아있으면 반격
|
||||
if (monsterStats.isAlive &&
|
||||
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(
|
||||
playerStats: playerStats,
|
||||
playerStats: buffedPlayerForDefense,
|
||||
monsterStats: monsterStats,
|
||||
activeDebuffs: activeDebuffs,
|
||||
totalDamageTaken: totalDamageTaken,
|
||||
@@ -168,7 +199,12 @@ class CombatTickService {
|
||||
calculator: calculator,
|
||||
);
|
||||
|
||||
playerStats = monsterAttackResult.playerStats;
|
||||
// 버프 적용된 스탯에서 HP/MP만 원본에 반영
|
||||
final defendedPlayer = monsterAttackResult.playerStats;
|
||||
playerStats = playerStats.copyWith(
|
||||
hpCurrent: defendedPlayer.hpCurrent,
|
||||
mpCurrent: defendedPlayer.mpCurrent,
|
||||
);
|
||||
totalDamageTaken = monsterAttackResult.totalDamageTaken;
|
||||
newEvents.addAll(monsterAttackResult.events);
|
||||
monsterAccumulator -= monsterStats.attackDelayMs;
|
||||
@@ -363,249 +399,6 @@ class CombatTickService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 플레이어 공격 처리
|
||||
({
|
||||
CombatStats playerStats,
|
||||
MonsterCombatStats monsterStats,
|
||||
SkillSystemState skillSystem,
|
||||
List<DotEffect> activeDoTs,
|
||||
List<ActiveBuff> activeDebuffs,
|
||||
int totalDamageDealt,
|
||||
List<CombatEvent> events,
|
||||
bool isFirstPlayerAttack,
|
||||
})
|
||||
_processPlayerAttack({
|
||||
required GameState state,
|
||||
required CombatStats playerStats,
|
||||
required MonsterCombatStats monsterStats,
|
||||
required SkillSystemState updatedSkillSystem,
|
||||
required List<DotEffect> activeDoTs,
|
||||
required List<ActiveBuff> activeDebuffs,
|
||||
required int totalDamageDealt,
|
||||
required int timestamp,
|
||||
required CombatCalculator calculator,
|
||||
required SkillService skillService,
|
||||
required bool isFirstPlayerAttack,
|
||||
required double firstStrikeBonus,
|
||||
required bool hasMultiAttack,
|
||||
double healingMultiplier = 1.0,
|
||||
}) {
|
||||
final events = <CombatEvent>[];
|
||||
var newPlayerStats = playerStats;
|
||||
var newMonsterStats = monsterStats;
|
||||
var newSkillSystem = updatedSkillSystem;
|
||||
var newActiveDoTs = [...activeDoTs];
|
||||
var newActiveBuffs = [...activeDebuffs];
|
||||
var newTotalDamageDealt = totalDamageDealt;
|
||||
|
||||
// 장착된 스킬 슬롯에서 사용 가능한 스킬 ID 목록 조회
|
||||
var availableSkillIds = state.skillSystem.equippedSkills.allSkills
|
||||
.map((s) => s.id)
|
||||
.toList();
|
||||
// 장착된 스킬이 없으면 기본 스킬 사용
|
||||
if (availableSkillIds.isEmpty) {
|
||||
availableSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
|
||||
final selectedSkill = skillService.selectAutoSkill(
|
||||
player: newPlayerStats,
|
||||
monster: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
availableSkillIds: availableSkillIds,
|
||||
activeDoTs: newActiveDoTs,
|
||||
activeDebuffs: newActiveBuffs,
|
||||
);
|
||||
|
||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||
// 스킬 랭크 조회
|
||||
final skillRank = skillService.getSkillRankFromSkillBook(
|
||||
state.skillBook,
|
||||
selectedSkill.id,
|
||||
);
|
||||
// 랭크 스케일링 적용된 공격 스킬 사용
|
||||
final skillResult = skillService.useAttackSkillWithRank(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
monster: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
rank: skillRank,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newMonsterStats = skillResult.updatedMonster;
|
||||
newTotalDamageDealt += skillResult.result.damage;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerSkill(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
damage: skillResult.result.damage,
|
||||
targetName: newMonsterStats.name,
|
||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||
final skillResult = skillService.useDotSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
playerInt: state.stats.intelligence,
|
||||
playerWis: state.stats.wis,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
if (skillResult.dotEffect != null) {
|
||||
newActiveDoTs.add(skillResult.dotEffect!);
|
||||
}
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerSkill(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
damage: skillResult.result.damage,
|
||||
targetName: newMonsterStats.name,
|
||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||
final skillResult = skillService.useHealSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
healingMultiplier: healingMultiplier,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerHeal(
|
||||
timestamp: timestamp,
|
||||
healAmount: skillResult.result.healedAmount,
|
||||
skillName: selectedSkill.name,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isBuff) {
|
||||
final skillResult = skillService.useBuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerBuff(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||
final skillResult = skillService.useDebuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
currentDebuffs: newActiveBuffs,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
if (skillResult.debuffEffect != null) {
|
||||
newActiveBuffs =
|
||||
newActiveBuffs
|
||||
.where(
|
||||
(d) => d.effect.id != skillResult.debuffEffect!.effect.id,
|
||||
)
|
||||
.toList()
|
||||
..add(skillResult.debuffEffect!);
|
||||
}
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerDebuff(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
targetName: newMonsterStats.name,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// 일반 공격
|
||||
final attackResult = calculator.playerAttackMonster(
|
||||
attacker: newPlayerStats,
|
||||
defender: newMonsterStats,
|
||||
);
|
||||
newMonsterStats = attackResult.updatedDefender;
|
||||
|
||||
// 첫 공격 배율 적용 (예: Pointer Assassin 1.5배)
|
||||
var damage = attackResult.result.damage;
|
||||
if (isFirstPlayerAttack && firstStrikeBonus > 1.0) {
|
||||
damage = (damage * firstStrikeBonus).round();
|
||||
// 첫 공격 배율이 적용된 데미지로 몬스터 HP 재계산
|
||||
final extraDamage = damage - attackResult.result.damage;
|
||||
if (extraDamage > 0) {
|
||||
final newHp = (newMonsterStats.hpCurrent - extraDamage).clamp(
|
||||
0,
|
||||
newMonsterStats.hpMax,
|
||||
);
|
||||
newMonsterStats = newMonsterStats.copyWith(hpCurrent: newHp);
|
||||
}
|
||||
}
|
||||
newTotalDamageDealt += damage;
|
||||
|
||||
final result = attackResult.result;
|
||||
if (result.isEvaded) {
|
||||
events.add(
|
||||
CombatEvent.monsterEvade(
|
||||
timestamp: timestamp,
|
||||
targetName: newMonsterStats.name,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
events.add(
|
||||
CombatEvent.playerAttack(
|
||||
timestamp: timestamp,
|
||||
damage: damage,
|
||||
targetName: newMonsterStats.name,
|
||||
isCritical: result.isCritical,
|
||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 연속 공격 (Refactor Monk 패시브) - 30% 확률로 추가 공격
|
||||
if (hasMultiAttack && newMonsterStats.isAlive && rng.nextDouble() < 0.3) {
|
||||
final extraAttack = calculator.playerAttackMonster(
|
||||
attacker: newPlayerStats,
|
||||
defender: newMonsterStats,
|
||||
);
|
||||
newMonsterStats = extraAttack.updatedDefender;
|
||||
newTotalDamageDealt += extraAttack.result.damage;
|
||||
|
||||
if (!extraAttack.result.isEvaded) {
|
||||
events.add(
|
||||
CombatEvent.playerAttack(
|
||||
timestamp: timestamp,
|
||||
damage: extraAttack.result.damage,
|
||||
targetName: newMonsterStats.name,
|
||||
isCritical: extraAttack.result.isCritical,
|
||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
playerStats: newPlayerStats,
|
||||
monsterStats: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
activeDoTs: newActiveDoTs,
|
||||
activeDebuffs: newActiveBuffs,
|
||||
totalDamageDealt: newTotalDamageDealt,
|
||||
events: events,
|
||||
isFirstPlayerAttack: false, // 첫 공격 이후에는 false
|
||||
);
|
||||
}
|
||||
|
||||
/// 몬스터 공격 처리
|
||||
({CombatStats playerStats, int totalDamageTaken, List<CombatEvent> events})
|
||||
_processMonsterAttack({
|
||||
|
||||
172
lib/src/core/engine/death_handler.dart
Normal file
172
lib/src/core/engine/death_handler.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
import 'package: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: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)
|
||||
///
|
||||
|
||||
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/util/pq_logic.dart' as pq_logic;
|
||||
|
||||
/// Game state mutations that mirror the original PQ win/reward logic.
|
||||
/// 게임 상태 변경(mutation) 함수 — 보상, 장비, 스탯 획득 로직.
|
||||
class GameMutations {
|
||||
const GameMutations(this.config);
|
||||
|
||||
@@ -26,6 +26,7 @@ class GameMutations {
|
||||
name: name,
|
||||
slot: slot,
|
||||
level: level,
|
||||
cha: state.stats.cha,
|
||||
);
|
||||
|
||||
final updatedEquip = state.equipment
|
||||
|
||||
@@ -60,23 +60,34 @@ class ItemService {
|
||||
// 희귀도 결정
|
||||
// ============================================================================
|
||||
|
||||
/// 희귀도 결정 (고정 확률)
|
||||
/// 희귀도 결정 (고정 확률 + CHA 보정)
|
||||
///
|
||||
/// 확률 분포:
|
||||
/// 기본 확률 분포:
|
||||
/// - Common: 34%
|
||||
/// - Uncommon: 40%
|
||||
/// - Rare: 20%
|
||||
/// - Epic: 5%
|
||||
/// - 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);
|
||||
|
||||
// Legendary: 0-0 (1%)
|
||||
if (roll < 1) return ItemRarity.legendary;
|
||||
// Epic: 1-5 (5%)
|
||||
if (roll < 6) return ItemRarity.epic;
|
||||
// Rare: 6-25 (20%)
|
||||
if (roll < 26) return ItemRarity.rare;
|
||||
// CHA 보정: (CHA - 10) * 0.5, 0~10 범위
|
||||
final chaBonus = ((cha - 10) * 0.5).clamp(0.0, 10.0);
|
||||
|
||||
// 보정된 임계값 (chaBonus만큼 희귀 쪽으로 이동)
|
||||
final legendaryThreshold = 1.0 + chaBonus * 0.1; // 최대 2%
|
||||
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%)
|
||||
if (roll < 66) return ItemRarity.uncommon;
|
||||
// Common: 66-99 (34%)
|
||||
@@ -331,8 +342,9 @@ class ItemService {
|
||||
required EquipmentSlot slot,
|
||||
required int level,
|
||||
ItemRarity? rarity,
|
||||
int cha = 0,
|
||||
}) {
|
||||
final itemRarity = rarity ?? determineRarity(level);
|
||||
final itemRarity = rarity ?? determineRarity(level, cha: cha);
|
||||
final stats = generateItemStats(
|
||||
level: level,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class MarketService {
|
||||
slot: slot,
|
||||
targetRarity: ItemRarity.common,
|
||||
);
|
||||
final price = shopService.calculateBuyPrice(item);
|
||||
final price = shopService.calculateBuyPrice(item, cha: state.stats.cha);
|
||||
|
||||
if (nextState.inventory.gold >= price) {
|
||||
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(
|
||||
equipment: newEquipment,
|
||||
@@ -105,6 +105,7 @@ class ResurrectionService {
|
||||
playerLevel: state.traits.level,
|
||||
currentGold: state.inventory.gold,
|
||||
currentEquipment: state.equipment,
|
||||
cha: state.stats.cha,
|
||||
);
|
||||
|
||||
// 장비 적용
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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/iap_service.dart';
|
||||
import 'package:asciineverdie/src/core/infrastructure/iap_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
|
||||
|
||||
/// 복귀 보상 서비스 (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;
|
||||
@@ -163,7 +168,7 @@ class ReturnRewardsService {
|
||||
// 무료 유저는 리워드 광고 필요
|
||||
List<ChestReward> bonusRewards = [];
|
||||
final adResult = await AdService.instance.showRewardedAd(
|
||||
adType: AdType.rewardRevive, // 복귀 보상용 리워드 광고
|
||||
adType: AdType.rewardReturn, // 복귀 보상용 리워드 광고
|
||||
onRewarded: () {
|
||||
bonusRewards = openChests(reward.bonusChestCount, playerLevel);
|
||||
},
|
||||
|
||||
@@ -18,10 +18,21 @@ class ShopService {
|
||||
|
||||
/// 장비 구매 가격 계산
|
||||
///
|
||||
/// 가격 = 아이템 레벨 * 50 * 희귀도 배율
|
||||
int calculateBuyPrice(EquipmentItem item) {
|
||||
/// 가격 = 아이템 레벨 * 50 * 희귀도 배율 * (1 - CHA 할인율)
|
||||
/// CHA 할인율(charisma discount): (CHA - 10) * 1%, 최대 15%, 최소 0%
|
||||
int calculateBuyPrice(EquipmentItem item, {int cha = 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 currentGold,
|
||||
required Equipment currentEquipment,
|
||||
int cha = 0,
|
||||
}) {
|
||||
var remainingGold = currentGold;
|
||||
final purchasedItems = <EquipmentItem>[];
|
||||
@@ -230,7 +242,7 @@ class ShopService {
|
||||
targetRarity: ItemRarity.common, // 부활 시 Common만 구매
|
||||
);
|
||||
|
||||
final price = calculateBuyPrice(shopItem);
|
||||
final price = calculateBuyPrice(shopItem, cha: cha);
|
||||
if (price <= remainingGold) {
|
||||
remainingGold -= price;
|
||||
purchasedItems.add(shopItem);
|
||||
@@ -254,6 +266,7 @@ class ShopService {
|
||||
required int currentGold,
|
||||
required EquipmentSlot slot,
|
||||
ItemRarity? preferredRarity,
|
||||
int cha = 0,
|
||||
}) {
|
||||
final item = generateShopItem(
|
||||
playerLevel: playerLevel,
|
||||
@@ -261,7 +274,7 @@ class ShopService {
|
||||
targetRarity: preferredRarity,
|
||||
);
|
||||
|
||||
final price = calculateBuyPrice(item);
|
||||
final price = calculateBuyPrice(item, cha: cha);
|
||||
if (price > currentGold) return null;
|
||||
|
||||
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/src/core/engine/skill_auto_selector.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
@@ -309,20 +310,12 @@ class SkillService {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 자동 스킬 선택
|
||||
// 자동 스킬 선택 (SkillAutoSelector에 위임)
|
||||
// ============================================================================
|
||||
|
||||
/// 전투 중 자동 스킬 선택
|
||||
///
|
||||
/// 우선순위:
|
||||
/// 1. HP < 30% → 회복 스킬 (최우선)
|
||||
/// 2. 70% 확률로 일반 공격 (MP 절약, 기본 전투)
|
||||
/// 3. 30% 확률로 스킬 사용:
|
||||
/// - 버프: HP > 80% & MP > 60% & 활성 버프 없음
|
||||
/// - 디버프: 몬스터 HP > 80% & 활성 디버프 없음
|
||||
/// - DOT: 몬스터 HP > 60% & 활성 DOT 없음
|
||||
/// - 공격: 보스전이면 강력한 스킬, 일반전이면 효율적 스킬
|
||||
/// 4. MP < 20% → 일반 공격
|
||||
/// 세부 로직은 SkillAutoSelector에 위임.
|
||||
Skill? selectAutoSkill({
|
||||
required CombatStats player,
|
||||
required MonsterCombatStats monster,
|
||||
@@ -331,186 +324,22 @@ class SkillService {
|
||||
List<DotEffect> activeDoTs = const [],
|
||||
List<ActiveBuff> activeDebuffs = const [],
|
||||
}) {
|
||||
final currentMp = player.mpCurrent;
|
||||
final mpRatio = player.mpRatio;
|
||||
final hpRatio = player.hpRatio;
|
||||
|
||||
// MP 20% 미만이면 일반 공격
|
||||
if (mpRatio < 0.2) return null;
|
||||
|
||||
// 사용 가능한 스킬 필터링
|
||||
final availableSkills = availableSkillIds
|
||||
.map((id) => SkillData.getSkillById(id))
|
||||
.whereType<Skill>()
|
||||
.where(
|
||||
(skill) =>
|
||||
final selector = SkillAutoSelector(rng: rng);
|
||||
return selector.selectAutoSkill(
|
||||
player: player,
|
||||
monster: monster,
|
||||
skillSystem: skillSystem,
|
||||
availableSkillIds: availableSkillIds,
|
||||
canUse: (skill) =>
|
||||
canUseSkill(
|
||||
skill: skill,
|
||||
currentMp: currentMp,
|
||||
currentMp: player.mpCurrent,
|
||||
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),
|
||||
activeDoTs: activeDoTs,
|
||||
activeDebuffs: activeDebuffs,
|
||||
);
|
||||
return attackSkills.first;
|
||||
}
|
||||
|
||||
/// MP 효율 좋은 공격 스킬 찾기
|
||||
Skill? _findEfficientAttackSkill(List<Skill> skills) {
|
||||
final attackSkills = skills.where((s) => s.isAttack).toList();
|
||||
if (attackSkills.isEmpty) return null;
|
||||
|
||||
attackSkills.sort((a, b) => b.mpEfficiency.compareTo(a.mpEfficiency));
|
||||
return attackSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 좋은 버프 스킬 찾기
|
||||
///
|
||||
/// ATK 증가 버프 우선, 그 다음 복합 버프
|
||||
Skill? _findBestBuffSkill(List<Skill> skills, int currentMp) {
|
||||
final buffSkills = skills
|
||||
.where((s) => s.isBuff && s.mpCost <= currentMp && s.buff != null)
|
||||
.toList();
|
||||
|
||||
if (buffSkills.isEmpty) return null;
|
||||
|
||||
// ATK 증가량 기준 정렬
|
||||
buffSkills.sort((a, b) {
|
||||
final aValue =
|
||||
(a.buff?.atkModifier ?? 0) +
|
||||
(a.buff?.defModifier ?? 0) * 0.5 +
|
||||
(a.buff?.criRateModifier ?? 0) * 0.3;
|
||||
final bValue =
|
||||
(b.buff?.atkModifier ?? 0) +
|
||||
(b.buff?.defModifier ?? 0) * 0.5 +
|
||||
(b.buff?.criRateModifier ?? 0) * 0.3;
|
||||
return bValue.compareTo(aValue);
|
||||
});
|
||||
|
||||
return buffSkills.first;
|
||||
}
|
||||
|
||||
/// 가장 좋은 디버프 스킬 찾기
|
||||
///
|
||||
/// 적 ATK 감소 디버프 우선
|
||||
Skill? _findBestDebuffSkill(List<Skill> skills, int currentMp) {
|
||||
final debuffSkills = skills
|
||||
.where((s) => s.isDebuff && s.mpCost <= currentMp && s.buff != null)
|
||||
.toList();
|
||||
|
||||
if (debuffSkills.isEmpty) return null;
|
||||
|
||||
// 디버프 효과 크기 기준 정렬 (음수 값이므로 절대값으로 비교)
|
||||
debuffSkills.sort((a, b) {
|
||||
final aValue =
|
||||
(a.buff?.atkModifier ?? 0).abs() +
|
||||
(a.buff?.defModifier ?? 0).abs() * 0.5;
|
||||
final bValue =
|
||||
(b.buff?.atkModifier ?? 0).abs() +
|
||||
(b.buff?.defModifier ?? 0).abs() * 0.5;
|
||||
return bValue.compareTo(aValue);
|
||||
});
|
||||
|
||||
return debuffSkills.first;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
259
lib/src/core/engine/task_generator.dart
Normal file
259
lib/src/core/engine/task_generator.dart
Normal file
@@ -0,0 +1,259 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/core/engine/act_progression_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
||||
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
||||
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
||||
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||
|
||||
/// 태스크 생성 서비스
|
||||
///
|
||||
/// ProgressService에서 분리된 다음 태스크 생성 로직 담당:
|
||||
/// - 시장 이동, 전환 태스크, 보스 리트라이, 몬스터 전투 생성
|
||||
class TaskGenerator {
|
||||
const TaskGenerator({required this.config});
|
||||
|
||||
final PqConfig config;
|
||||
|
||||
/// 큐가 비어있을 때 다음 태스크 생성 (원본 Dequeue 667-684줄)
|
||||
({ProgressState progress, QueueState queue}) generateNextTask(
|
||||
GameState state,
|
||||
) {
|
||||
var progress = state.progress;
|
||||
final queue = state.queue;
|
||||
final oldTaskType = progress.currentTask.type;
|
||||
|
||||
// 1. Encumbrance 초과 시 시장 이동
|
||||
if (_shouldGoToMarket(progress)) {
|
||||
return _createMarketTask(progress, queue);
|
||||
}
|
||||
|
||||
// 2. 전환 태스크 (buying/heading)
|
||||
if (_needsTransitionTask(oldTaskType)) {
|
||||
return _createTransitionTask(state, progress, queue);
|
||||
}
|
||||
|
||||
// 3. Act Boss 리트라이
|
||||
if (state.progress.pendingActCompletion) {
|
||||
return _createActBossRetryTask(state, progress, queue);
|
||||
}
|
||||
|
||||
// 4. 최종 보스 전투
|
||||
if (state.progress.finalBossState == FinalBossState.fighting &&
|
||||
!state.progress.isInBossLevelingMode) {
|
||||
if (state.progress.bossLevelingEndTime != null) {
|
||||
progress = progress.copyWith(clearBossLevelingEndTime: true);
|
||||
}
|
||||
final actProgressionService = ActProgressionService(config: config);
|
||||
return actProgressionService.startFinalBossFight(state, progress, queue);
|
||||
}
|
||||
|
||||
// 5. 일반 몬스터 전투
|
||||
return _createMonsterTask(state, progress, queue);
|
||||
}
|
||||
|
||||
/// 시장 이동 조건 확인
|
||||
bool _shouldGoToMarket(ProgressState progress) {
|
||||
return progress.encumbrance.position >= progress.encumbrance.max &&
|
||||
progress.encumbrance.max > 0;
|
||||
}
|
||||
|
||||
/// 전환 태스크 필요 여부 확인
|
||||
bool _needsTransitionTask(TaskType oldTaskType) {
|
||||
return oldTaskType != TaskType.kill &&
|
||||
oldTaskType != TaskType.neutral &&
|
||||
oldTaskType != TaskType.buying;
|
||||
}
|
||||
|
||||
/// 시장 이동 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createMarketTask(
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskHeadingToMarket(),
|
||||
4 * 1000,
|
||||
);
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.market),
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// 전환 태스크 생성 (buying 또는 heading)
|
||||
({ProgressState progress, QueueState queue}) _createTransitionTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final gold = state.inventory.gold;
|
||||
final equipPrice = state.traits.level * 50;
|
||||
|
||||
// Gold 충분 시 장비 구매
|
||||
if (gold > equipPrice) {
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskUpgradingHardware(),
|
||||
5 * 1000,
|
||||
);
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.buying,
|
||||
),
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
// Gold 부족 시 전장 이동
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskEnteringDebugZone(),
|
||||
4 * 1000,
|
||||
);
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.neutral,
|
||||
),
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// Act Boss 재도전 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createActBossRetryTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final actProgressionService = ActProgressionService(config: config);
|
||||
final actBoss = actProgressionService.createActBoss(state);
|
||||
final combatCalculator = CombatCalculator(rng: state.rng);
|
||||
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
||||
player: actBoss.playerStats,
|
||||
monster: actBoss.monsterStats,
|
||||
);
|
||||
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskDebugging(actBoss.monsterStats.name),
|
||||
durationMillis,
|
||||
);
|
||||
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.kill,
|
||||
monsterBaseName: actBoss.monsterStats.name,
|
||||
monsterPart: '*',
|
||||
monsterLevel: actBoss.monsterStats.level,
|
||||
monsterGrade: MonsterGrade.boss,
|
||||
monsterSize: getBossSizeForAct(state.progress.plotStageCount),
|
||||
),
|
||||
currentCombat: actBoss,
|
||||
);
|
||||
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// 일반 몬스터 전투 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createMonsterTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final level = state.traits.level;
|
||||
|
||||
// 퀘스트 몬스터 데이터 확인
|
||||
final questMonster = state.progress.currentQuestMonster;
|
||||
final questMonsterData = questMonster?.monsterData;
|
||||
final questLevel = questMonsterData != null
|
||||
? int.tryParse(questMonsterData.split('|').elementAtOrNull(1) ?? '') ??
|
||||
0
|
||||
: null;
|
||||
|
||||
// 몬스터 생성
|
||||
final monsterResult = pq_logic.monsterTask(
|
||||
config,
|
||||
state.rng,
|
||||
level,
|
||||
questMonsterData,
|
||||
questLevel,
|
||||
);
|
||||
|
||||
// 몬스터 레벨 조정 (밸런스)
|
||||
final actMinLevel = ActMonsterLevel.forPlotStage(
|
||||
state.progress.plotStageCount,
|
||||
);
|
||||
final baseLevel = math.max(level, actMinLevel);
|
||||
final effectiveMonsterLevel = monsterResult.level
|
||||
.clamp(math.max(1, baseLevel - 3), baseLevel + 3)
|
||||
.toInt();
|
||||
|
||||
// 전투 스탯 생성
|
||||
final playerCombatStats = CombatStats.fromStats(
|
||||
stats: state.stats,
|
||||
equipment: state.equipment,
|
||||
level: level,
|
||||
monsterLevel: effectiveMonsterLevel,
|
||||
);
|
||||
|
||||
final monsterCombatStats = MonsterCombatStats.fromLevel(
|
||||
name: monsterResult.displayName,
|
||||
level: effectiveMonsterLevel,
|
||||
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
|
||||
plotStageCount: state.progress.plotStageCount,
|
||||
);
|
||||
|
||||
// 전투 상태 및 지속시간
|
||||
final combatState = CombatState.start(
|
||||
playerStats: playerCombatStats,
|
||||
monsterStats: monsterCombatStats,
|
||||
);
|
||||
|
||||
final combatCalculator = CombatCalculator(rng: state.rng);
|
||||
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
||||
player: playerCombatStats,
|
||||
monster: monsterCombatStats,
|
||||
);
|
||||
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskDebugging(monsterResult.displayName),
|
||||
durationMillis,
|
||||
);
|
||||
|
||||
// 몬스터 사이즈 결정
|
||||
final monsterSize = getMonsterSizeForAct(
|
||||
plotStageCount: state.progress.plotStageCount,
|
||||
grade: monsterResult.grade,
|
||||
rng: state.rng,
|
||||
);
|
||||
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.kill,
|
||||
monsterBaseName: monsterResult.baseName,
|
||||
monsterPart: monsterResult.part,
|
||||
monsterLevel: effectiveMonsterLevel,
|
||||
monsterGrade: monsterResult.grade,
|
||||
monsterSize: monsterSize,
|
||||
),
|
||||
currentCombat: combatState,
|
||||
);
|
||||
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/engine/iap_service.dart';
|
||||
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 {
|
||||
@@ -20,6 +23,9 @@ enum AdType {
|
||||
|
||||
/// 속도업용 인터스티셜 광고 (6초)
|
||||
interstitialSpeed,
|
||||
|
||||
/// 복귀 보상용 리워드 광고 (30초)
|
||||
rewardReturn,
|
||||
}
|
||||
|
||||
/// 광고 결과
|
||||
@@ -41,16 +47,14 @@ enum AdResult {
|
||||
///
|
||||
/// AdMob 리워드/인터스티셜 광고 로드 및 표시를 관리합니다.
|
||||
/// 디버그 모드에서는 광고 ON/OFF 토글이 가능합니다.
|
||||
class AdService {
|
||||
class AdService implements IAdService {
|
||||
AdService._();
|
||||
|
||||
static AdService? _instance;
|
||||
/// GetIt 등록용 팩토리 메서드
|
||||
factory AdService.createInstance() => AdService._();
|
||||
|
||||
/// 싱글톤 인스턴스
|
||||
static AdService get instance {
|
||||
_instance ??= AdService._();
|
||||
return _instance!;
|
||||
}
|
||||
/// 싱글톤 인스턴스 (GetIt 서비스 로케이터에 위임)
|
||||
static IAdService get instance => GetIt.instance<IAdService>();
|
||||
|
||||
// ===========================================================================
|
||||
// 광고 단위 ID
|
||||
@@ -71,15 +75,14 @@ class AdService {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 프로덕션 광고 ID (AdMob 콘솔에서 생성 후 교체)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// TODO: AdMob 콘솔에서 광고 단위 생성 후 아래 ID 교체
|
||||
static const String _prodRewardedAndroid =
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 리워드 광고
|
||||
static const String _prodRewardedIos =
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 리워드 광고
|
||||
'ca-app-pub-6691216385521068/3457464395'; // Android 리워드 광고
|
||||
// TODO(ios): AdMob iOS 광고 ID — iOS 출시 전 필수 교체
|
||||
static const String _prodRewardedIos = _testRewardedIos;
|
||||
static const String _prodInterstitialAndroid =
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // Android 인터스티셜 광고
|
||||
static const String _prodInterstitialIos =
|
||||
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // iOS 인터스티셜 광고
|
||||
'ca-app-pub-6691216385521068/1625507977'; // Android 인터스티셜 광고
|
||||
// TODO(ios): AdMob iOS 광고 ID — iOS 출시 전 필수 교체
|
||||
static const String _prodInterstitialIos = _testInterstitialIos;
|
||||
|
||||
/// 리워드 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID)
|
||||
String get _rewardAdUnitId {
|
||||
@@ -124,6 +127,7 @@ class AdService {
|
||||
// ===========================================================================
|
||||
|
||||
/// AdMob SDK 초기화
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
@@ -195,6 +199,7 @@ class AdService {
|
||||
}
|
||||
|
||||
/// 리워드 광고 준비 여부
|
||||
@override
|
||||
bool get isRewardedAdReady => _rewardedAd != null || _shouldSkipAd;
|
||||
|
||||
/// 리워드 광고 표시
|
||||
@@ -202,6 +207,7 @@ class AdService {
|
||||
/// [adType] 광고 타입 (로깅용)
|
||||
/// [onRewarded] 보상 지급 콜백
|
||||
/// Returns: 광고 결과
|
||||
@override
|
||||
Future<AdResult> showRewardedAd({
|
||||
required AdType adType,
|
||||
required void Function() onRewarded,
|
||||
@@ -306,6 +312,7 @@ class AdService {
|
||||
}
|
||||
|
||||
/// 인터스티셜 광고 준비 여부
|
||||
@override
|
||||
bool get isInterstitialAdReady => _interstitialAd != null || _shouldSkipAd;
|
||||
|
||||
/// 인터스티셜 광고 표시
|
||||
@@ -313,6 +320,7 @@ class AdService {
|
||||
/// [adType] 광고 타입 (로깅용)
|
||||
/// [onComplete] 광고 완료 콜백 (보상 지급)
|
||||
/// Returns: 광고 결과
|
||||
@override
|
||||
Future<AdResult> showInterstitialAd({
|
||||
required AdType adType,
|
||||
required void Function() onComplete,
|
||||
@@ -365,6 +373,7 @@ class AdService {
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
ad.dispose();
|
||||
onComplete();
|
||||
_loadInterstitialAd();
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(AdResult.failed);
|
||||
@@ -383,6 +392,7 @@ class AdService {
|
||||
// ===========================================================================
|
||||
|
||||
/// 리소스 해제
|
||||
@override
|
||||
void dispose() {
|
||||
_rewardedAd?.dispose();
|
||||
_rewardedAd = null;
|
||||
@@ -1,19 +1,26 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
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: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/src/core/di/i_iap_service.dart';
|
||||
|
||||
/// IAP 상품 ID
|
||||
class IAPProductIds {
|
||||
IAPProductIds._();
|
||||
|
||||
/// 광고 제거 상품 ID (비소모성)
|
||||
/// TODO: Google Play Console / App Store Connect에서 상품 생성 후 ID 교체
|
||||
static const String removeAds = 'remove_ads';
|
||||
static const String removeAds = 'remove_ads_and';
|
||||
|
||||
/// 모든 상품 ID 목록
|
||||
static const Set<String> all = {removeAds};
|
||||
@@ -43,20 +50,27 @@ enum IAPResult {
|
||||
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 서비스
|
||||
///
|
||||
/// 인앱 구매 (광고 제거) 처리를 담당합니다.
|
||||
/// shared_preferences를 사용하여 구매 상태를 영구 저장합니다.
|
||||
class IAPService {
|
||||
/// flutter_secure_storage를 사용하여 구매 상태를 보안 저장합니다.
|
||||
class IAPService implements IIAPService {
|
||||
IAPService._();
|
||||
|
||||
static IAPService? _instance;
|
||||
/// GetIt 등록용 팩토리 메서드
|
||||
factory IAPService.createInstance() => IAPService._();
|
||||
|
||||
/// 싱글톤 인스턴스
|
||||
static IAPService get instance {
|
||||
_instance ??= IAPService._();
|
||||
return _instance!;
|
||||
}
|
||||
/// 싱글톤 인스턴스 (GetIt 서비스 로케이터에 위임)
|
||||
static IIAPService get instance => GetIt.instance<IIAPService>();
|
||||
|
||||
// ===========================================================================
|
||||
// 상수
|
||||
@@ -65,11 +79,14 @@ class IAPService {
|
||||
/// 구매 상태 저장 키
|
||||
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 _isAvailable = false;
|
||||
@@ -91,6 +108,7 @@ class IAPService {
|
||||
// ===========================================================================
|
||||
|
||||
/// IAP 서비스 초기화
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
@@ -124,6 +142,12 @@ class IAPService {
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('[IAPService] Initialized');
|
||||
|
||||
// 로컬에 구매 기록이 없으면 스토어에서 자동 복원 시도
|
||||
// (앱 삭제 후 재설치 대응, 비동기로 실행하여 초기화 블로킹 없음)
|
||||
if (!_adRemovalPurchased) {
|
||||
_tryAutoRestore();
|
||||
}
|
||||
}
|
||||
|
||||
/// 상품 정보 로드
|
||||
@@ -144,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 {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_adRemovalPurchased = prefs.getBool(_purchaseKey) ?? false;
|
||||
final value = await _secureStorage.read(key: _purchaseKey);
|
||||
_adRemovalPurchased = value == 'true';
|
||||
debugPrint('[IAPService] Loaded purchase state: $_adRemovalPurchased');
|
||||
}
|
||||
|
||||
/// 구매 상태 저장
|
||||
/// 구매 상태 저장 (보안 저장소 사용)
|
||||
Future<void> _savePurchaseState(bool purchased) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_purchaseKey, purchased);
|
||||
await _secureStorage.write(key: _purchaseKey, value: purchased.toString());
|
||||
_adRemovalPurchased = purchased;
|
||||
debugPrint('[IAPService] Saved purchase state: $purchased');
|
||||
}
|
||||
@@ -164,9 +198,11 @@ class IAPService {
|
||||
// ===========================================================================
|
||||
|
||||
/// 디버그 모드 IAP 시뮬레이션 활성화 여부
|
||||
@override
|
||||
bool get debugIAPSimulated => _debugIAPSimulated;
|
||||
|
||||
/// 디버그 모드 IAP 시뮬레이션 토글
|
||||
@override
|
||||
set debugIAPSimulated(bool value) {
|
||||
_debugIAPSimulated = value;
|
||||
if (kDebugMode) {
|
||||
@@ -180,18 +216,21 @@ class IAPService {
|
||||
// ===========================================================================
|
||||
|
||||
/// 광고 제거 구매 여부
|
||||
@override
|
||||
bool get isAdRemovalPurchased {
|
||||
if (kDebugMode && _debugIAPSimulated) return true;
|
||||
return _adRemovalPurchased;
|
||||
}
|
||||
|
||||
/// 스토어 가용성
|
||||
@override
|
||||
bool get isStoreAvailable => _isAvailable;
|
||||
|
||||
/// 광고 제거 상품 정보
|
||||
ProductDetails? get removeAdsProduct => _removeAdsProduct;
|
||||
|
||||
/// 광고 제거 상품 가격 문자열
|
||||
@override
|
||||
String get removeAdsPrice {
|
||||
if (_removeAdsProduct != null) {
|
||||
return _removeAdsProduct!.price;
|
||||
@@ -209,6 +248,7 @@ class IAPService {
|
||||
// ===========================================================================
|
||||
|
||||
/// 광고 제거 구매
|
||||
@override
|
||||
Future<IAPResult> purchaseRemoveAds() async {
|
||||
// 디버그 모드 시뮬레이션
|
||||
if (kDebugMode && _debugIAPSimulated) {
|
||||
@@ -253,6 +293,7 @@ class IAPService {
|
||||
// ===========================================================================
|
||||
|
||||
/// 구매 복원
|
||||
@override
|
||||
Future<IAPResult> restorePurchases() async {
|
||||
// 디버그 모드 시뮬레이션
|
||||
if (kDebugMode && _debugIAPSimulated) {
|
||||
@@ -312,18 +353,128 @@ class IAPService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 구매 성공 처리
|
||||
/// 구매 성공 처리 (서명 검증 포함)
|
||||
Future<void> _handleSuccessfulPurchase(PurchaseDetails purchase) async {
|
||||
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);
|
||||
debugPrint('[IAPService] Ad removal purchased successfully');
|
||||
debugPrint('[IAPService] Ad removal purchased & verified successfully');
|
||||
}
|
||||
|
||||
// 구매 완료 처리
|
||||
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 {
|
||||
if (purchase.pendingCompletePurchase) {
|
||||
@@ -337,6 +488,7 @@ class IAPService {
|
||||
// ===========================================================================
|
||||
|
||||
/// 리소스 해제
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
_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);
|
||||
},
|
||||
);
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ mixin _$EquipmentItem {
|
||||
String get name => throw _privateConstructorUsedError;
|
||||
|
||||
/// 장착 슬롯
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
|
||||
EquipmentSlot get slot => throw _privateConstructorUsedError;
|
||||
|
||||
@@ -38,6 +39,7 @@ mixin _$EquipmentItem {
|
||||
ItemStats get stats => throw _privateConstructorUsedError;
|
||||
|
||||
/// 희귀도
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
|
||||
ItemRarity get rarity => throw _privateConstructorUsedError;
|
||||
|
||||
@@ -231,6 +233,7 @@ class _$EquipmentItemImpl extends _EquipmentItem {
|
||||
final String name;
|
||||
|
||||
/// 장착 슬롯
|
||||
// ignore: invalid_annotation_target
|
||||
@override
|
||||
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
|
||||
final EquipmentSlot slot;
|
||||
@@ -248,6 +251,7 @@ class _$EquipmentItemImpl extends _EquipmentItem {
|
||||
final ItemStats stats;
|
||||
|
||||
/// 희귀도
|
||||
// ignore: invalid_annotation_target
|
||||
@override
|
||||
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
|
||||
final ItemRarity rarity;
|
||||
@@ -305,6 +309,7 @@ abstract class _EquipmentItem extends EquipmentItem {
|
||||
String get name;
|
||||
|
||||
/// 장착 슬롯
|
||||
// ignore: invalid_annotation_target
|
||||
@override
|
||||
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
|
||||
EquipmentSlot get slot;
|
||||
@@ -322,6 +327,7 @@ abstract class _EquipmentItem extends EquipmentItem {
|
||||
ItemStats get stats;
|
||||
|
||||
/// 희귀도
|
||||
// ignore: invalid_annotation_target
|
||||
@override
|
||||
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
|
||||
ItemRarity get rarity;
|
||||
|
||||
@@ -28,8 +28,7 @@ import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// 게임 전체 상태 (Game State)
|
||||
///
|
||||
/// Progress Quest 구조를 미러링하는 최소 스켈레톤 상태.
|
||||
/// 로직은 Delphi 소스에서 충실하게 포팅됨.
|
||||
/// 게임 진행에 필요한 모든 데이터를 포함하는 불변(immutable) 상태 객체.
|
||||
class GameState {
|
||||
GameState({
|
||||
required DeterministicRandom rng,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import 'package:asciineverdie/src/core/model/cumulative_statistics.dart';
|
||||
import 'package:asciineverdie/src/core/model/session_statistics.dart';
|
||||
|
||||
// 하위 호환성(backward compatibility)을 위한 re-export
|
||||
export 'package:asciineverdie/src/core/model/cumulative_statistics.dart';
|
||||
export 'package:asciineverdie/src/core/model/session_statistics.dart';
|
||||
|
||||
/// 게임 통계 (Game Statistics)
|
||||
///
|
||||
/// 세션 및 누적 통계를 추적하는 모델
|
||||
/// 세션 및 누적 통계를 추적하는 모델.
|
||||
/// 세부 구현은 SessionStatistics와 CumulativeStatistics로 분리됨.
|
||||
class GameStatistics {
|
||||
const GameStatistics({required this.session, required this.cumulative});
|
||||
|
||||
@@ -59,558 +67,3 @@ class GameStatistics {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 세션 통계 (Session Statistics)
|
||||
///
|
||||
/// 현재 게임 세션의 통계
|
||||
class SessionStatistics {
|
||||
const SessionStatistics({
|
||||
required this.playTimeMs,
|
||||
required this.monstersKilled,
|
||||
required this.goldEarned,
|
||||
required this.goldSpent,
|
||||
required this.skillsUsed,
|
||||
required this.criticalHits,
|
||||
required this.maxCriticalStreak,
|
||||
required this.currentCriticalStreak,
|
||||
required this.totalDamageDealt,
|
||||
required this.totalDamageTaken,
|
||||
required this.potionsUsed,
|
||||
required this.itemsSold,
|
||||
required this.questsCompleted,
|
||||
required this.deathCount,
|
||||
required this.bossesDefeated,
|
||||
required this.levelUps,
|
||||
});
|
||||
|
||||
/// 플레이 시간 (밀리초)
|
||||
final int playTimeMs;
|
||||
|
||||
/// 처치한 몬스터 수
|
||||
final int monstersKilled;
|
||||
|
||||
/// 획득한 골드 총량
|
||||
final int goldEarned;
|
||||
|
||||
/// 소비한 골드 총량
|
||||
final int goldSpent;
|
||||
|
||||
/// 사용한 스킬 횟수
|
||||
final int skillsUsed;
|
||||
|
||||
/// 크리티컬 히트 횟수
|
||||
final int criticalHits;
|
||||
|
||||
/// 최대 연속 크리티컬
|
||||
final int maxCriticalStreak;
|
||||
|
||||
/// 현재 연속 크리티컬 (내부 추적용)
|
||||
final int currentCriticalStreak;
|
||||
|
||||
/// 총 입힌 데미지
|
||||
final int totalDamageDealt;
|
||||
|
||||
/// 총 받은 데미지
|
||||
final int totalDamageTaken;
|
||||
|
||||
/// 사용한 물약 수
|
||||
final int potionsUsed;
|
||||
|
||||
/// 판매한 아이템 수
|
||||
final int itemsSold;
|
||||
|
||||
/// 완료한 퀘스트 수
|
||||
final int questsCompleted;
|
||||
|
||||
/// 사망 횟수
|
||||
final int deathCount;
|
||||
|
||||
/// 처치한 보스 수
|
||||
final int bossesDefeated;
|
||||
|
||||
/// 레벨업 횟수
|
||||
final int levelUps;
|
||||
|
||||
/// 플레이 시간 Duration
|
||||
Duration get playTime => Duration(milliseconds: playTimeMs);
|
||||
|
||||
/// 플레이 시간 포맷 (HH:MM:SS)
|
||||
String get formattedPlayTime {
|
||||
final hours = playTime.inHours;
|
||||
final minutes = playTime.inMinutes % 60;
|
||||
final seconds = playTime.inSeconds % 60;
|
||||
return '${hours.toString().padLeft(2, '0')}:'
|
||||
'${minutes.toString().padLeft(2, '0')}:'
|
||||
'${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 평균 DPS (damage per second)
|
||||
double get averageDps {
|
||||
if (playTimeMs <= 0) return 0;
|
||||
return totalDamageDealt / (playTimeMs / 1000);
|
||||
}
|
||||
|
||||
/// 킬당 평균 골드
|
||||
double get goldPerKill {
|
||||
if (monstersKilled <= 0) return 0;
|
||||
return goldEarned / monstersKilled;
|
||||
}
|
||||
|
||||
/// 크리티컬 비율
|
||||
double get criticalRate {
|
||||
if (skillsUsed <= 0) return 0;
|
||||
return criticalHits / skillsUsed;
|
||||
}
|
||||
|
||||
/// 빈 세션 통계
|
||||
factory SessionStatistics.empty() => const SessionStatistics(
|
||||
playTimeMs: 0,
|
||||
monstersKilled: 0,
|
||||
goldEarned: 0,
|
||||
goldSpent: 0,
|
||||
skillsUsed: 0,
|
||||
criticalHits: 0,
|
||||
maxCriticalStreak: 0,
|
||||
currentCriticalStreak: 0,
|
||||
totalDamageDealt: 0,
|
||||
totalDamageTaken: 0,
|
||||
potionsUsed: 0,
|
||||
itemsSold: 0,
|
||||
questsCompleted: 0,
|
||||
deathCount: 0,
|
||||
bossesDefeated: 0,
|
||||
levelUps: 0,
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 이벤트 기록 메서드
|
||||
// ============================================================================
|
||||
|
||||
/// 몬스터 처치 기록
|
||||
SessionStatistics recordKill({bool isBoss = false}) {
|
||||
return copyWith(
|
||||
monstersKilled: monstersKilled + 1,
|
||||
bossesDefeated: isBoss ? bossesDefeated + 1 : bossesDefeated,
|
||||
);
|
||||
}
|
||||
|
||||
/// 골드 획득 기록
|
||||
SessionStatistics recordGoldEarned(int amount) {
|
||||
return copyWith(goldEarned: goldEarned + amount);
|
||||
}
|
||||
|
||||
/// 골드 소비 기록
|
||||
SessionStatistics recordGoldSpent(int amount) {
|
||||
return copyWith(goldSpent: goldSpent + amount);
|
||||
}
|
||||
|
||||
/// 스킬 사용 기록
|
||||
SessionStatistics recordSkillUse({required bool isCritical}) {
|
||||
final newCriticalStreak = isCritical ? currentCriticalStreak + 1 : 0;
|
||||
final newMaxStreak = newCriticalStreak > maxCriticalStreak
|
||||
? newCriticalStreak
|
||||
: maxCriticalStreak;
|
||||
|
||||
return copyWith(
|
||||
skillsUsed: skillsUsed + 1,
|
||||
criticalHits: isCritical ? criticalHits + 1 : criticalHits,
|
||||
currentCriticalStreak: newCriticalStreak,
|
||||
maxCriticalStreak: newMaxStreak,
|
||||
);
|
||||
}
|
||||
|
||||
/// 데미지 기록
|
||||
SessionStatistics recordDamage({int dealt = 0, int taken = 0}) {
|
||||
return copyWith(
|
||||
totalDamageDealt: totalDamageDealt + dealt,
|
||||
totalDamageTaken: totalDamageTaken + taken,
|
||||
);
|
||||
}
|
||||
|
||||
/// 물약 사용 기록
|
||||
SessionStatistics recordPotionUse() {
|
||||
return copyWith(potionsUsed: potionsUsed + 1);
|
||||
}
|
||||
|
||||
/// 아이템 판매 기록
|
||||
SessionStatistics recordItemSold(int count) {
|
||||
return copyWith(itemsSold: itemsSold + count);
|
||||
}
|
||||
|
||||
/// 퀘스트 완료 기록
|
||||
SessionStatistics recordQuestComplete() {
|
||||
return copyWith(questsCompleted: questsCompleted + 1);
|
||||
}
|
||||
|
||||
/// 사망 기록
|
||||
SessionStatistics recordDeath() {
|
||||
return copyWith(deathCount: deathCount + 1);
|
||||
}
|
||||
|
||||
/// 레벨업 기록
|
||||
SessionStatistics recordLevelUp() {
|
||||
return copyWith(levelUps: levelUps + 1);
|
||||
}
|
||||
|
||||
/// 플레이 시간 업데이트
|
||||
SessionStatistics updatePlayTime(int elapsedMs) {
|
||||
return copyWith(playTimeMs: elapsedMs);
|
||||
}
|
||||
|
||||
SessionStatistics copyWith({
|
||||
int? playTimeMs,
|
||||
int? monstersKilled,
|
||||
int? goldEarned,
|
||||
int? goldSpent,
|
||||
int? skillsUsed,
|
||||
int? criticalHits,
|
||||
int? maxCriticalStreak,
|
||||
int? currentCriticalStreak,
|
||||
int? totalDamageDealt,
|
||||
int? totalDamageTaken,
|
||||
int? potionsUsed,
|
||||
int? itemsSold,
|
||||
int? questsCompleted,
|
||||
int? deathCount,
|
||||
int? bossesDefeated,
|
||||
int? levelUps,
|
||||
}) {
|
||||
return SessionStatistics(
|
||||
playTimeMs: playTimeMs ?? this.playTimeMs,
|
||||
monstersKilled: monstersKilled ?? this.monstersKilled,
|
||||
goldEarned: goldEarned ?? this.goldEarned,
|
||||
goldSpent: goldSpent ?? this.goldSpent,
|
||||
skillsUsed: skillsUsed ?? this.skillsUsed,
|
||||
criticalHits: criticalHits ?? this.criticalHits,
|
||||
maxCriticalStreak: maxCriticalStreak ?? this.maxCriticalStreak,
|
||||
currentCriticalStreak:
|
||||
currentCriticalStreak ?? this.currentCriticalStreak,
|
||||
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
|
||||
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
|
||||
potionsUsed: potionsUsed ?? this.potionsUsed,
|
||||
itemsSold: itemsSold ?? this.itemsSold,
|
||||
questsCompleted: questsCompleted ?? this.questsCompleted,
|
||||
deathCount: deathCount ?? this.deathCount,
|
||||
bossesDefeated: bossesDefeated ?? this.bossesDefeated,
|
||||
levelUps: levelUps ?? this.levelUps,
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON 직렬화
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'playTimeMs': playTimeMs,
|
||||
'monstersKilled': monstersKilled,
|
||||
'goldEarned': goldEarned,
|
||||
'goldSpent': goldSpent,
|
||||
'skillsUsed': skillsUsed,
|
||||
'criticalHits': criticalHits,
|
||||
'maxCriticalStreak': maxCriticalStreak,
|
||||
'totalDamageDealt': totalDamageDealt,
|
||||
'totalDamageTaken': totalDamageTaken,
|
||||
'potionsUsed': potionsUsed,
|
||||
'itemsSold': itemsSold,
|
||||
'questsCompleted': questsCompleted,
|
||||
'deathCount': deathCount,
|
||||
'bossesDefeated': bossesDefeated,
|
||||
'levelUps': levelUps,
|
||||
};
|
||||
}
|
||||
|
||||
/// JSON 역직렬화
|
||||
factory SessionStatistics.fromJson(Map<String, dynamic> json) {
|
||||
return SessionStatistics(
|
||||
playTimeMs: json['playTimeMs'] as int? ?? 0,
|
||||
monstersKilled: json['monstersKilled'] as int? ?? 0,
|
||||
goldEarned: json['goldEarned'] as int? ?? 0,
|
||||
goldSpent: json['goldSpent'] as int? ?? 0,
|
||||
skillsUsed: json['skillsUsed'] as int? ?? 0,
|
||||
criticalHits: json['criticalHits'] as int? ?? 0,
|
||||
maxCriticalStreak: json['maxCriticalStreak'] as int? ?? 0,
|
||||
currentCriticalStreak: 0, // 세션간 유지 안 함
|
||||
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
|
||||
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
|
||||
potionsUsed: json['potionsUsed'] as int? ?? 0,
|
||||
itemsSold: json['itemsSold'] as int? ?? 0,
|
||||
questsCompleted: json['questsCompleted'] as int? ?? 0,
|
||||
deathCount: json['deathCount'] as int? ?? 0,
|
||||
bossesDefeated: json['bossesDefeated'] as int? ?? 0,
|
||||
levelUps: json['levelUps'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 누적 통계 (Cumulative Statistics)
|
||||
///
|
||||
/// 모든 게임 세션의 누적 통계
|
||||
class CumulativeStatistics {
|
||||
const CumulativeStatistics({
|
||||
required this.totalPlayTimeMs,
|
||||
required this.totalMonstersKilled,
|
||||
required this.totalGoldEarned,
|
||||
required this.totalGoldSpent,
|
||||
required this.totalSkillsUsed,
|
||||
required this.totalCriticalHits,
|
||||
required this.bestCriticalStreak,
|
||||
required this.totalDamageDealt,
|
||||
required this.totalDamageTaken,
|
||||
required this.totalPotionsUsed,
|
||||
required this.totalItemsSold,
|
||||
required this.totalQuestsCompleted,
|
||||
required this.totalDeaths,
|
||||
required this.totalBossesDefeated,
|
||||
required this.totalLevelUps,
|
||||
required this.highestLevel,
|
||||
required this.highestGoldHeld,
|
||||
required this.gamesCompleted,
|
||||
required this.gamesStarted,
|
||||
});
|
||||
|
||||
/// 총 플레이 시간 (밀리초)
|
||||
final int totalPlayTimeMs;
|
||||
|
||||
/// 총 처치한 몬스터 수
|
||||
final int totalMonstersKilled;
|
||||
|
||||
/// 총 획득한 골드
|
||||
final int totalGoldEarned;
|
||||
|
||||
/// 총 소비한 골드
|
||||
final int totalGoldSpent;
|
||||
|
||||
/// 총 스킬 사용 횟수
|
||||
final int totalSkillsUsed;
|
||||
|
||||
/// 총 크리티컬 히트 횟수
|
||||
final int totalCriticalHits;
|
||||
|
||||
/// 최고 연속 크리티컬
|
||||
final int bestCriticalStreak;
|
||||
|
||||
/// 총 입힌 데미지
|
||||
final int totalDamageDealt;
|
||||
|
||||
/// 총 받은 데미지
|
||||
final int totalDamageTaken;
|
||||
|
||||
/// 총 사용한 물약 수
|
||||
final int totalPotionsUsed;
|
||||
|
||||
/// 총 판매한 아이템 수
|
||||
final int totalItemsSold;
|
||||
|
||||
/// 총 완료한 퀘스트 수
|
||||
final int totalQuestsCompleted;
|
||||
|
||||
/// 총 사망 횟수
|
||||
final int totalDeaths;
|
||||
|
||||
/// 총 처치한 보스 수
|
||||
final int totalBossesDefeated;
|
||||
|
||||
/// 총 레벨업 횟수
|
||||
final int totalLevelUps;
|
||||
|
||||
/// 최고 달성 레벨
|
||||
final int highestLevel;
|
||||
|
||||
/// 최대 보유 골드
|
||||
final int highestGoldHeld;
|
||||
|
||||
/// 클리어한 게임 수
|
||||
final int gamesCompleted;
|
||||
|
||||
/// 시작한 게임 수
|
||||
final int gamesStarted;
|
||||
|
||||
/// 총 플레이 시간 Duration
|
||||
Duration get totalPlayTime => Duration(milliseconds: totalPlayTimeMs);
|
||||
|
||||
/// 총 플레이 시간 포맷 (HH:MM:SS)
|
||||
String get formattedTotalPlayTime {
|
||||
final hours = totalPlayTime.inHours;
|
||||
final minutes = totalPlayTime.inMinutes % 60;
|
||||
final seconds = totalPlayTime.inSeconds % 60;
|
||||
return '${hours.toString().padLeft(2, '0')}:'
|
||||
'${minutes.toString().padLeft(2, '0')}:'
|
||||
'${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 평균 게임당 플레이 시간
|
||||
Duration get averagePlayTimePerGame {
|
||||
if (gamesStarted <= 0) return Duration.zero;
|
||||
return Duration(milliseconds: totalPlayTimeMs ~/ gamesStarted);
|
||||
}
|
||||
|
||||
/// 게임 완료율
|
||||
double get completionRate {
|
||||
if (gamesStarted <= 0) return 0;
|
||||
return gamesCompleted / gamesStarted;
|
||||
}
|
||||
|
||||
/// 빈 누적 통계
|
||||
factory CumulativeStatistics.empty() => const CumulativeStatistics(
|
||||
totalPlayTimeMs: 0,
|
||||
totalMonstersKilled: 0,
|
||||
totalGoldEarned: 0,
|
||||
totalGoldSpent: 0,
|
||||
totalSkillsUsed: 0,
|
||||
totalCriticalHits: 0,
|
||||
bestCriticalStreak: 0,
|
||||
totalDamageDealt: 0,
|
||||
totalDamageTaken: 0,
|
||||
totalPotionsUsed: 0,
|
||||
totalItemsSold: 0,
|
||||
totalQuestsCompleted: 0,
|
||||
totalDeaths: 0,
|
||||
totalBossesDefeated: 0,
|
||||
totalLevelUps: 0,
|
||||
highestLevel: 0,
|
||||
highestGoldHeld: 0,
|
||||
gamesCompleted: 0,
|
||||
gamesStarted: 0,
|
||||
);
|
||||
|
||||
/// 세션 통계 병합
|
||||
CumulativeStatistics mergeSession(SessionStatistics session) {
|
||||
return CumulativeStatistics(
|
||||
totalPlayTimeMs: totalPlayTimeMs + session.playTimeMs,
|
||||
totalMonstersKilled: totalMonstersKilled + session.monstersKilled,
|
||||
totalGoldEarned: totalGoldEarned + session.goldEarned,
|
||||
totalGoldSpent: totalGoldSpent + session.goldSpent,
|
||||
totalSkillsUsed: totalSkillsUsed + session.skillsUsed,
|
||||
totalCriticalHits: totalCriticalHits + session.criticalHits,
|
||||
bestCriticalStreak: session.maxCriticalStreak > bestCriticalStreak
|
||||
? session.maxCriticalStreak
|
||||
: bestCriticalStreak,
|
||||
totalDamageDealt: totalDamageDealt + session.totalDamageDealt,
|
||||
totalDamageTaken: totalDamageTaken + session.totalDamageTaken,
|
||||
totalPotionsUsed: totalPotionsUsed + session.potionsUsed,
|
||||
totalItemsSold: totalItemsSold + session.itemsSold,
|
||||
totalQuestsCompleted: totalQuestsCompleted + session.questsCompleted,
|
||||
totalDeaths: totalDeaths + session.deathCount,
|
||||
totalBossesDefeated: totalBossesDefeated + session.bossesDefeated,
|
||||
totalLevelUps: totalLevelUps + session.levelUps,
|
||||
highestLevel: highestLevel, // 별도 업데이트 필요
|
||||
highestGoldHeld: highestGoldHeld, // 별도 업데이트 필요
|
||||
gamesCompleted: gamesCompleted, // 별도 업데이트 필요
|
||||
gamesStarted: gamesStarted, // 별도 업데이트 필요
|
||||
);
|
||||
}
|
||||
|
||||
/// 최고 레벨 업데이트
|
||||
CumulativeStatistics updateHighestLevel(int level) {
|
||||
if (level <= highestLevel) return this;
|
||||
return copyWith(highestLevel: level);
|
||||
}
|
||||
|
||||
/// 최대 골드 업데이트
|
||||
CumulativeStatistics updateHighestGold(int gold) {
|
||||
if (gold <= highestGoldHeld) return this;
|
||||
return copyWith(highestGoldHeld: gold);
|
||||
}
|
||||
|
||||
/// 새 게임 시작 기록
|
||||
CumulativeStatistics recordGameStart() {
|
||||
return copyWith(gamesStarted: gamesStarted + 1);
|
||||
}
|
||||
|
||||
/// 게임 클리어 기록
|
||||
CumulativeStatistics recordGameComplete() {
|
||||
return copyWith(gamesCompleted: gamesCompleted + 1);
|
||||
}
|
||||
|
||||
CumulativeStatistics copyWith({
|
||||
int? totalPlayTimeMs,
|
||||
int? totalMonstersKilled,
|
||||
int? totalGoldEarned,
|
||||
int? totalGoldSpent,
|
||||
int? totalSkillsUsed,
|
||||
int? totalCriticalHits,
|
||||
int? bestCriticalStreak,
|
||||
int? totalDamageDealt,
|
||||
int? totalDamageTaken,
|
||||
int? totalPotionsUsed,
|
||||
int? totalItemsSold,
|
||||
int? totalQuestsCompleted,
|
||||
int? totalDeaths,
|
||||
int? totalBossesDefeated,
|
||||
int? totalLevelUps,
|
||||
int? highestLevel,
|
||||
int? highestGoldHeld,
|
||||
int? gamesCompleted,
|
||||
int? gamesStarted,
|
||||
}) {
|
||||
return CumulativeStatistics(
|
||||
totalPlayTimeMs: totalPlayTimeMs ?? this.totalPlayTimeMs,
|
||||
totalMonstersKilled: totalMonstersKilled ?? this.totalMonstersKilled,
|
||||
totalGoldEarned: totalGoldEarned ?? this.totalGoldEarned,
|
||||
totalGoldSpent: totalGoldSpent ?? this.totalGoldSpent,
|
||||
totalSkillsUsed: totalSkillsUsed ?? this.totalSkillsUsed,
|
||||
totalCriticalHits: totalCriticalHits ?? this.totalCriticalHits,
|
||||
bestCriticalStreak: bestCriticalStreak ?? this.bestCriticalStreak,
|
||||
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
|
||||
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
|
||||
totalPotionsUsed: totalPotionsUsed ?? this.totalPotionsUsed,
|
||||
totalItemsSold: totalItemsSold ?? this.totalItemsSold,
|
||||
totalQuestsCompleted: totalQuestsCompleted ?? this.totalQuestsCompleted,
|
||||
totalDeaths: totalDeaths ?? this.totalDeaths,
|
||||
totalBossesDefeated: totalBossesDefeated ?? this.totalBossesDefeated,
|
||||
totalLevelUps: totalLevelUps ?? this.totalLevelUps,
|
||||
highestLevel: highestLevel ?? this.highestLevel,
|
||||
highestGoldHeld: highestGoldHeld ?? this.highestGoldHeld,
|
||||
gamesCompleted: gamesCompleted ?? this.gamesCompleted,
|
||||
gamesStarted: gamesStarted ?? this.gamesStarted,
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON 직렬화
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'totalPlayTimeMs': totalPlayTimeMs,
|
||||
'totalMonstersKilled': totalMonstersKilled,
|
||||
'totalGoldEarned': totalGoldEarned,
|
||||
'totalGoldSpent': totalGoldSpent,
|
||||
'totalSkillsUsed': totalSkillsUsed,
|
||||
'totalCriticalHits': totalCriticalHits,
|
||||
'bestCriticalStreak': bestCriticalStreak,
|
||||
'totalDamageDealt': totalDamageDealt,
|
||||
'totalDamageTaken': totalDamageTaken,
|
||||
'totalPotionsUsed': totalPotionsUsed,
|
||||
'totalItemsSold': totalItemsSold,
|
||||
'totalQuestsCompleted': totalQuestsCompleted,
|
||||
'totalDeaths': totalDeaths,
|
||||
'totalBossesDefeated': totalBossesDefeated,
|
||||
'totalLevelUps': totalLevelUps,
|
||||
'highestLevel': highestLevel,
|
||||
'highestGoldHeld': highestGoldHeld,
|
||||
'gamesCompleted': gamesCompleted,
|
||||
'gamesStarted': gamesStarted,
|
||||
};
|
||||
}
|
||||
|
||||
/// JSON 역직렬화
|
||||
factory CumulativeStatistics.fromJson(Map<String, dynamic> json) {
|
||||
return CumulativeStatistics(
|
||||
totalPlayTimeMs: json['totalPlayTimeMs'] as int? ?? 0,
|
||||
totalMonstersKilled: json['totalMonstersKilled'] as int? ?? 0,
|
||||
totalGoldEarned: json['totalGoldEarned'] as int? ?? 0,
|
||||
totalGoldSpent: json['totalGoldSpent'] as int? ?? 0,
|
||||
totalSkillsUsed: json['totalSkillsUsed'] as int? ?? 0,
|
||||
totalCriticalHits: json['totalCriticalHits'] as int? ?? 0,
|
||||
bestCriticalStreak: json['bestCriticalStreak'] as int? ?? 0,
|
||||
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
|
||||
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
|
||||
totalPotionsUsed: json['totalPotionsUsed'] as int? ?? 0,
|
||||
totalItemsSold: json['totalItemsSold'] as int? ?? 0,
|
||||
totalQuestsCompleted: json['totalQuestsCompleted'] as int? ?? 0,
|
||||
totalDeaths: json['totalDeaths'] as int? ?? 0,
|
||||
totalBossesDefeated: json['totalBossesDefeated'] as int? ?? 0,
|
||||
totalLevelUps: json['totalLevelUps'] as int? ?? 0,
|
||||
highestLevel: json['highestLevel'] as int? ?? 0,
|
||||
highestGoldHeld: json['highestGoldHeld'] as int? ?? 0,
|
||||
gamesCompleted: json['gamesCompleted'] as int? ?? 0,
|
||||
gamesStarted: json['gamesStarted'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,18 +69,18 @@ class MonetizationState with _$MonetizationState {
|
||||
/// 무료 사용자 여부
|
||||
bool get isFreeUser => !adRemovalPurchased;
|
||||
|
||||
/// 자동부활 버프 활성 여부 (elapsedMs 기준)
|
||||
bool isAutoReviveActive(int elapsedMs) {
|
||||
/// 자동부활 버프 활성 여부 (실제 시간 기준)
|
||||
bool isAutoReviveActive([int? _]) {
|
||||
if (autoReviveEndMs == null) return false;
|
||||
return elapsedMs < autoReviveEndMs!;
|
||||
return DateTime.now().millisecondsSinceEpoch < autoReviveEndMs!;
|
||||
}
|
||||
|
||||
/// 5배속 버프 활성 여부 (elapsedMs 기준)
|
||||
/// 5배속 버프 활성 여부 (실제 시간 기준)
|
||||
/// 유료 사용자는 항상 활성
|
||||
bool isSpeedBoostActive(int elapsedMs) {
|
||||
bool isSpeedBoostActive([int? _]) {
|
||||
if (isPaidUser) return true;
|
||||
if (speedBoostEndMs == null) return false;
|
||||
return elapsedMs < speedBoostEndMs!;
|
||||
return DateTime.now().millisecondsSinceEpoch < speedBoostEndMs!;
|
||||
}
|
||||
|
||||
/// 행운의 부적 버프 활성 여부 (elapsedMs 기준)
|
||||
|
||||
@@ -31,6 +31,7 @@ mixin _$MonetizationState {
|
||||
int get undoRemaining => throw _privateConstructorUsedError;
|
||||
|
||||
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
|
||||
List<Stats>? get rollHistory => throw _privateConstructorUsedError;
|
||||
|
||||
@@ -41,6 +42,7 @@ mixin _$MonetizationState {
|
||||
int? get speedBoostEndMs => throw _privateConstructorUsedError;
|
||||
|
||||
/// 마지막 플레이 시각 (복귀 보상 계산용)
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
|
||||
DateTime? get lastPlayTime => throw _privateConstructorUsedError;
|
||||
|
||||
@@ -279,9 +281,11 @@ class _$MonetizationStateImpl extends _MonetizationState {
|
||||
final int undoRemaining;
|
||||
|
||||
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
|
||||
// ignore: invalid_annotation_target
|
||||
final List<Stats>? _rollHistory;
|
||||
|
||||
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
|
||||
// ignore: invalid_annotation_target
|
||||
@override
|
||||
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
|
||||
List<Stats>? get rollHistory {
|
||||
@@ -301,6 +305,7 @@ class _$MonetizationStateImpl extends _MonetizationState {
|
||||
final int? speedBoostEndMs;
|
||||
|
||||
/// 마지막 플레이 시각 (복귀 보상 계산용)
|
||||
// ignore: invalid_annotation_target
|
||||
@override
|
||||
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
|
||||
final DateTime? lastPlayTime;
|
||||
@@ -410,6 +415,7 @@ abstract class _MonetizationState extends MonetizationState {
|
||||
int get undoRemaining;
|
||||
|
||||
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
|
||||
// ignore: invalid_annotation_target
|
||||
@override
|
||||
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
|
||||
List<Stats>? get rollHistory;
|
||||
@@ -423,6 +429,7 @@ abstract class _MonetizationState extends MonetizationState {
|
||||
int? get speedBoostEndMs;
|
||||
|
||||
/// 마지막 플레이 시각 (복귀 보상 계산용)
|
||||
// ignore: invalid_annotation_target
|
||||
@override
|
||||
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
|
||||
DateTime? get lastPlayTime;
|
||||
|
||||
@@ -160,6 +160,7 @@ class ProgressState {
|
||||
bool? pendingActCompletion,
|
||||
int? bossLevelingEndTime,
|
||||
bool clearBossLevelingEndTime = false,
|
||||
bool clearCurrentCombat = false,
|
||||
}) {
|
||||
return ProgressState(
|
||||
task: task ?? this.task,
|
||||
@@ -173,7 +174,9 @@ class ProgressState {
|
||||
plotHistory: plotHistory ?? this.plotHistory,
|
||||
questHistory: questHistory ?? this.questHistory,
|
||||
currentQuestMonster: currentQuestMonster ?? this.currentQuestMonster,
|
||||
currentCombat: currentCombat ?? this.currentCombat,
|
||||
currentCombat: clearCurrentCombat
|
||||
? null
|
||||
: (currentCombat ?? this.currentCombat),
|
||||
monstersKilled: monstersKilled ?? this.monstersKilled,
|
||||
deathCount: deathCount ?? this.deathCount,
|
||||
finalBossState: finalBossState ?? this.finalBossState,
|
||||
|
||||
@@ -148,11 +148,16 @@ class GameSave {
|
||||
}
|
||||
|
||||
static GameSave fromJson(Map<String, dynamic> json) {
|
||||
final traitsJson = json['traits'] as Map<String, dynamic>;
|
||||
final statsJson = json['stats'] as Map<String, dynamic>;
|
||||
final inventoryJson = json['inventory'] as Map<String, dynamic>;
|
||||
final equipmentJson = json['equipment'] as Map<String, dynamic>;
|
||||
final progressJson = json['progress'] as Map<String, dynamic>;
|
||||
final traitsJson =
|
||||
json['traits'] as Map<String, dynamic>? ?? <String, dynamic>{};
|
||||
final statsJson =
|
||||
json['stats'] as Map<String, dynamic>? ?? <String, dynamic>{};
|
||||
final inventoryJson =
|
||||
json['inventory'] as Map<String, dynamic>? ?? <String, dynamic>{};
|
||||
final equipmentJson =
|
||||
json['equipment'] as Map<String, dynamic>? ?? <String, dynamic>{};
|
||||
final progressJson =
|
||||
json['progress'] as Map<String, dynamic>? ?? <String, dynamic>{};
|
||||
final queueJson = (json['queue'] as List<dynamic>? ?? []).cast<dynamic>();
|
||||
final skillsJson = (json['skills'] as List<dynamic>? ?? []).cast<dynamic>();
|
||||
|
||||
|
||||
279
lib/src/core/model/session_statistics.dart
Normal file
279
lib/src/core/model/session_statistics.dart
Normal file
@@ -0,0 +1,279 @@
|
||||
/// 세션 통계 (Session Statistics)
|
||||
///
|
||||
/// GameStatistics에서 분리된 현재 게임 세션의 통계 모델.
|
||||
class SessionStatistics {
|
||||
const SessionStatistics({
|
||||
required this.playTimeMs,
|
||||
required this.monstersKilled,
|
||||
required this.goldEarned,
|
||||
required this.goldSpent,
|
||||
required this.skillsUsed,
|
||||
required this.criticalHits,
|
||||
required this.maxCriticalStreak,
|
||||
required this.currentCriticalStreak,
|
||||
required this.totalDamageDealt,
|
||||
required this.totalDamageTaken,
|
||||
required this.potionsUsed,
|
||||
required this.itemsSold,
|
||||
required this.questsCompleted,
|
||||
required this.deathCount,
|
||||
required this.bossesDefeated,
|
||||
required this.levelUps,
|
||||
});
|
||||
|
||||
/// 플레이 시간 (밀리초)
|
||||
final int playTimeMs;
|
||||
|
||||
/// 처치한 몬스터 수
|
||||
final int monstersKilled;
|
||||
|
||||
/// 획득한 골드 총량
|
||||
final int goldEarned;
|
||||
|
||||
/// 소비한 골드 총량
|
||||
final int goldSpent;
|
||||
|
||||
/// 사용한 스킬 횟수
|
||||
final int skillsUsed;
|
||||
|
||||
/// 크리티컬 히트 횟수
|
||||
final int criticalHits;
|
||||
|
||||
/// 최대 연속 크리티컬
|
||||
final int maxCriticalStreak;
|
||||
|
||||
/// 현재 연속 크리티컬 (내부 추적용)
|
||||
final int currentCriticalStreak;
|
||||
|
||||
/// 총 입힌 데미지
|
||||
final int totalDamageDealt;
|
||||
|
||||
/// 총 받은 데미지
|
||||
final int totalDamageTaken;
|
||||
|
||||
/// 사용한 물약 수
|
||||
final int potionsUsed;
|
||||
|
||||
/// 판매한 아이템 수
|
||||
final int itemsSold;
|
||||
|
||||
/// 완료한 퀘스트 수
|
||||
final int questsCompleted;
|
||||
|
||||
/// 사망 횟수
|
||||
final int deathCount;
|
||||
|
||||
/// 처치한 보스 수
|
||||
final int bossesDefeated;
|
||||
|
||||
/// 레벨업 횟수
|
||||
final int levelUps;
|
||||
|
||||
/// 플레이 시간 Duration
|
||||
Duration get playTime => Duration(milliseconds: playTimeMs);
|
||||
|
||||
/// 플레이 시간 포맷 (HH:MM:SS)
|
||||
String get formattedPlayTime {
|
||||
final hours = playTime.inHours;
|
||||
final minutes = playTime.inMinutes % 60;
|
||||
final seconds = playTime.inSeconds % 60;
|
||||
return '${hours.toString().padLeft(2, '0')}:'
|
||||
'${minutes.toString().padLeft(2, '0')}:'
|
||||
'${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 평균 DPS (damage per second)
|
||||
double get averageDps {
|
||||
if (playTimeMs <= 0) return 0;
|
||||
return totalDamageDealt / (playTimeMs / 1000);
|
||||
}
|
||||
|
||||
/// 킬당 평균 골드
|
||||
double get goldPerKill {
|
||||
if (monstersKilled <= 0) return 0;
|
||||
return goldEarned / monstersKilled;
|
||||
}
|
||||
|
||||
/// 크리티컬 비율
|
||||
double get criticalRate {
|
||||
if (skillsUsed <= 0) return 0;
|
||||
return criticalHits / skillsUsed;
|
||||
}
|
||||
|
||||
/// 빈 세션 통계
|
||||
factory SessionStatistics.empty() => const SessionStatistics(
|
||||
playTimeMs: 0,
|
||||
monstersKilled: 0,
|
||||
goldEarned: 0,
|
||||
goldSpent: 0,
|
||||
skillsUsed: 0,
|
||||
criticalHits: 0,
|
||||
maxCriticalStreak: 0,
|
||||
currentCriticalStreak: 0,
|
||||
totalDamageDealt: 0,
|
||||
totalDamageTaken: 0,
|
||||
potionsUsed: 0,
|
||||
itemsSold: 0,
|
||||
questsCompleted: 0,
|
||||
deathCount: 0,
|
||||
bossesDefeated: 0,
|
||||
levelUps: 0,
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 이벤트 기록 메서드
|
||||
// ============================================================================
|
||||
|
||||
/// 몬스터 처치 기록
|
||||
SessionStatistics recordKill({bool isBoss = false}) {
|
||||
return copyWith(
|
||||
monstersKilled: monstersKilled + 1,
|
||||
bossesDefeated: isBoss ? bossesDefeated + 1 : bossesDefeated,
|
||||
);
|
||||
}
|
||||
|
||||
/// 골드 획득 기록
|
||||
SessionStatistics recordGoldEarned(int amount) {
|
||||
return copyWith(goldEarned: goldEarned + amount);
|
||||
}
|
||||
|
||||
/// 골드 소비 기록
|
||||
SessionStatistics recordGoldSpent(int amount) {
|
||||
return copyWith(goldSpent: goldSpent + amount);
|
||||
}
|
||||
|
||||
/// 스킬 사용 기록
|
||||
SessionStatistics recordSkillUse({required bool isCritical}) {
|
||||
final newCriticalStreak = isCritical ? currentCriticalStreak + 1 : 0;
|
||||
final newMaxStreak = newCriticalStreak > maxCriticalStreak
|
||||
? newCriticalStreak
|
||||
: maxCriticalStreak;
|
||||
|
||||
return copyWith(
|
||||
skillsUsed: skillsUsed + 1,
|
||||
criticalHits: isCritical ? criticalHits + 1 : criticalHits,
|
||||
currentCriticalStreak: newCriticalStreak,
|
||||
maxCriticalStreak: newMaxStreak,
|
||||
);
|
||||
}
|
||||
|
||||
/// 데미지 기록
|
||||
SessionStatistics recordDamage({int dealt = 0, int taken = 0}) {
|
||||
return copyWith(
|
||||
totalDamageDealt: totalDamageDealt + dealt,
|
||||
totalDamageTaken: totalDamageTaken + taken,
|
||||
);
|
||||
}
|
||||
|
||||
/// 물약 사용 기록
|
||||
SessionStatistics recordPotionUse() {
|
||||
return copyWith(potionsUsed: potionsUsed + 1);
|
||||
}
|
||||
|
||||
/// 아이템 판매 기록
|
||||
SessionStatistics recordItemSold(int count) {
|
||||
return copyWith(itemsSold: itemsSold + count);
|
||||
}
|
||||
|
||||
/// 퀘스트 완료 기록
|
||||
SessionStatistics recordQuestComplete() {
|
||||
return copyWith(questsCompleted: questsCompleted + 1);
|
||||
}
|
||||
|
||||
/// 사망 기록
|
||||
SessionStatistics recordDeath() {
|
||||
return copyWith(deathCount: deathCount + 1);
|
||||
}
|
||||
|
||||
/// 레벨업 기록
|
||||
SessionStatistics recordLevelUp() {
|
||||
return copyWith(levelUps: levelUps + 1);
|
||||
}
|
||||
|
||||
/// 플레이 시간 업데이트
|
||||
SessionStatistics updatePlayTime(int elapsedMs) {
|
||||
return copyWith(playTimeMs: elapsedMs);
|
||||
}
|
||||
|
||||
SessionStatistics copyWith({
|
||||
int? playTimeMs,
|
||||
int? monstersKilled,
|
||||
int? goldEarned,
|
||||
int? goldSpent,
|
||||
int? skillsUsed,
|
||||
int? criticalHits,
|
||||
int? maxCriticalStreak,
|
||||
int? currentCriticalStreak,
|
||||
int? totalDamageDealt,
|
||||
int? totalDamageTaken,
|
||||
int? potionsUsed,
|
||||
int? itemsSold,
|
||||
int? questsCompleted,
|
||||
int? deathCount,
|
||||
int? bossesDefeated,
|
||||
int? levelUps,
|
||||
}) {
|
||||
return SessionStatistics(
|
||||
playTimeMs: playTimeMs ?? this.playTimeMs,
|
||||
monstersKilled: monstersKilled ?? this.monstersKilled,
|
||||
goldEarned: goldEarned ?? this.goldEarned,
|
||||
goldSpent: goldSpent ?? this.goldSpent,
|
||||
skillsUsed: skillsUsed ?? this.skillsUsed,
|
||||
criticalHits: criticalHits ?? this.criticalHits,
|
||||
maxCriticalStreak: maxCriticalStreak ?? this.maxCriticalStreak,
|
||||
currentCriticalStreak:
|
||||
currentCriticalStreak ?? this.currentCriticalStreak,
|
||||
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
|
||||
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
|
||||
potionsUsed: potionsUsed ?? this.potionsUsed,
|
||||
itemsSold: itemsSold ?? this.itemsSold,
|
||||
questsCompleted: questsCompleted ?? this.questsCompleted,
|
||||
deathCount: deathCount ?? this.deathCount,
|
||||
bossesDefeated: bossesDefeated ?? this.bossesDefeated,
|
||||
levelUps: levelUps ?? this.levelUps,
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON 직렬화
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'playTimeMs': playTimeMs,
|
||||
'monstersKilled': monstersKilled,
|
||||
'goldEarned': goldEarned,
|
||||
'goldSpent': goldSpent,
|
||||
'skillsUsed': skillsUsed,
|
||||
'criticalHits': criticalHits,
|
||||
'maxCriticalStreak': maxCriticalStreak,
|
||||
'totalDamageDealt': totalDamageDealt,
|
||||
'totalDamageTaken': totalDamageTaken,
|
||||
'potionsUsed': potionsUsed,
|
||||
'itemsSold': itemsSold,
|
||||
'questsCompleted': questsCompleted,
|
||||
'deathCount': deathCount,
|
||||
'bossesDefeated': bossesDefeated,
|
||||
'levelUps': levelUps,
|
||||
};
|
||||
}
|
||||
|
||||
/// JSON 역직렬화
|
||||
factory SessionStatistics.fromJson(Map<String, dynamic> json) {
|
||||
return SessionStatistics(
|
||||
playTimeMs: json['playTimeMs'] as int? ?? 0,
|
||||
monstersKilled: json['monstersKilled'] as int? ?? 0,
|
||||
goldEarned: json['goldEarned'] as int? ?? 0,
|
||||
goldSpent: json['goldSpent'] as int? ?? 0,
|
||||
skillsUsed: json['skillsUsed'] as int? ?? 0,
|
||||
criticalHits: json['criticalHits'] as int? ?? 0,
|
||||
maxCriticalStreak: json['maxCriticalStreak'] as int? ?? 0,
|
||||
currentCriticalStreak: 0, // 세션간 유지 안 함
|
||||
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
|
||||
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
|
||||
potionsUsed: json['potionsUsed'] as int? ?? 0,
|
||||
itemsSold: json['itemsSold'] as int? ?? 0,
|
||||
questsCompleted: json['questsCompleted'] as int? ?? 0,
|
||||
deathCount: json['deathCount'] as int? ?? 0,
|
||||
bossesDefeated: json['bossesDefeated'] as int? ?? 0,
|
||||
levelUps: json['levelUps'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:asciineverdie/src/core/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
||||
|
||||
/// 태스크 타입 (원본 fTask.Caption 값들에 대응)
|
||||
|
||||
168
lib/src/core/storage/save_integrity.dart
Normal file
168
lib/src/core/storage/save_integrity.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// 세이브 파일 HMAC-SHA256 무결성(integrity) 검증 유틸리티.
|
||||
///
|
||||
/// 파일 포맷: [32-byte HMAC][GZip data]
|
||||
/// 구 포맷(legacy): [GZip data] (HMAC 없음, GZip 매직 바이트 0x1f 0x8b로 시작)
|
||||
class SaveIntegrity {
|
||||
SaveIntegrity._();
|
||||
|
||||
/// HMAC-SHA256 출력 길이 (bytes)
|
||||
static const int hmacLength = 32;
|
||||
|
||||
/// GZip 매직 바이트 (magic bytes) — 구 포맷 판별용
|
||||
static const int _gzipMagic1 = 0x1f;
|
||||
static const int _gzipMagic2 = 0x8b;
|
||||
|
||||
/// 난독화(obfuscation)된 HMAC 키 생성.
|
||||
/// 소스에 평문(plaintext)으로 저장하지 않기 위해 XOR 분할.
|
||||
static List<int> get _hmacKey {
|
||||
// 파트 A: 원본 키의 전반부
|
||||
const partA = <int>[
|
||||
0x41,
|
||||
0x73,
|
||||
0x63,
|
||||
0x69,
|
||||
0x69,
|
||||
0x4e,
|
||||
0x65,
|
||||
0x76,
|
||||
0x65,
|
||||
0x72,
|
||||
0x44,
|
||||
0x69,
|
||||
0x65,
|
||||
0x53,
|
||||
0x61,
|
||||
0x76,
|
||||
];
|
||||
// 파트 B: XOR 마스크(mask)
|
||||
const mask = <int>[
|
||||
0x7a,
|
||||
0x1c,
|
||||
0x0f,
|
||||
0x05,
|
||||
0x0d,
|
||||
0x22,
|
||||
0x09,
|
||||
0x1a,
|
||||
0x09,
|
||||
0x1e,
|
||||
0x28,
|
||||
0x05,
|
||||
0x09,
|
||||
0x3f,
|
||||
0x0d,
|
||||
0x1a,
|
||||
];
|
||||
// 파트 C: partA XOR mask 결과 (키 후반부)
|
||||
const partC = <int>[
|
||||
0x3b,
|
||||
0x6f,
|
||||
0x6c,
|
||||
0x6c,
|
||||
0x64,
|
||||
0x6c,
|
||||
0x6c,
|
||||
0x6c,
|
||||
0x6c,
|
||||
0x6c,
|
||||
0x6c,
|
||||
0x6c,
|
||||
0x6c,
|
||||
0x6c,
|
||||
0x6c,
|
||||
0x6c,
|
||||
];
|
||||
|
||||
// 전반부(partA) + 후반부(partC XOR mask)로 32바이트 키 생성
|
||||
final key = List<int>.filled(32, 0);
|
||||
for (var i = 0; i < 16; i++) {
|
||||
key[i] = partA[i];
|
||||
key[i + 16] = partC[i] ^ mask[i];
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/// GZip 데이터에 HMAC-SHA256 서명(signature) 추가.
|
||||
/// 반환: [32-byte HMAC][gzipBytes]
|
||||
static Uint8List sign(List<int> gzipBytes) {
|
||||
final mac = _computeHmac(gzipBytes);
|
||||
final result = Uint8List(hmacLength + gzipBytes.length);
|
||||
result.setRange(0, hmacLength, mac.bytes);
|
||||
result.setRange(hmacLength, result.length, gzipBytes);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 파일 바이트에서 HMAC를 검증(verify)하고 GZip 데이터를 반환.
|
||||
///
|
||||
/// - HMAC 검증 성공: GZip 바이트 반환
|
||||
/// - 구 포맷(legacy, HMAC 없음): GZip 바이트 그대로 반환 + [isLegacy] = true
|
||||
/// - HMAC 검증 실패: [SaveIntegrityException] 발생
|
||||
static SaveIntegrityResult verify(List<int> fileBytes) {
|
||||
if (_isLegacyFormat(fileBytes)) {
|
||||
return SaveIntegrityResult(
|
||||
gzipBytes: Uint8List.fromList(fileBytes),
|
||||
isLegacy: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (fileBytes.length < hmacLength) {
|
||||
throw const SaveIntegrityException('파일이 너무 작습니다');
|
||||
}
|
||||
|
||||
final storedHmac = fileBytes.sublist(0, hmacLength);
|
||||
final gzipBytes = fileBytes.sublist(hmacLength);
|
||||
final computed = _computeHmac(gzipBytes);
|
||||
|
||||
// 상수 시간(constant-time) 비교로 타이밍 공격(timing attack) 방지
|
||||
var match = true;
|
||||
for (var i = 0; i < hmacLength; i++) {
|
||||
if (storedHmac[i] != computed.bytes[i]) {
|
||||
match = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
throw const SaveIntegrityException('세이브 파일 무결성 검증 실패');
|
||||
}
|
||||
|
||||
return SaveIntegrityResult(
|
||||
gzipBytes: Uint8List.fromList(gzipBytes),
|
||||
isLegacy: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 구 포맷 판별: GZip 매직 바이트(0x1f 0x8b)로 시작하면 HMAC 없는 레거시
|
||||
static bool _isLegacyFormat(List<int> bytes) {
|
||||
if (bytes.length < 2) return false;
|
||||
return bytes[0] == _gzipMagic1 && bytes[1] == _gzipMagic2;
|
||||
}
|
||||
|
||||
static Digest _computeHmac(List<int> data) {
|
||||
final hmac = Hmac(sha256, _hmacKey);
|
||||
return hmac.convert(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// HMAC 검증 결과(result)
|
||||
class SaveIntegrityResult {
|
||||
const SaveIntegrityResult({required this.gzipBytes, required this.isLegacy});
|
||||
|
||||
/// HMAC을 제외한 순수 GZip 데이터
|
||||
final Uint8List gzipBytes;
|
||||
|
||||
/// 구 포맷(legacy) 여부 — true면 HMAC 없이 로드됨
|
||||
final bool isLegacy;
|
||||
}
|
||||
|
||||
/// 세이브 파일 무결성 검증 실패 예외(exception)
|
||||
class SaveIntegrityException implements Exception {
|
||||
const SaveIntegrityException(this.message);
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'SaveIntegrityException: $message';
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:asciineverdie/src/core/model/save_data.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_integrity.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_service.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
@@ -45,6 +46,8 @@ class SaveRepository {
|
||||
} on FileSystemException catch (e) {
|
||||
final reason = e.osError?.message ?? e.message;
|
||||
return (SaveOutcome.failure('Unable to load save: $reason'), null);
|
||||
} on SaveIntegrityException catch (e) {
|
||||
return (SaveOutcome.failure('Tampered save file: ${e.message}'), null);
|
||||
} on FormatException catch (e) {
|
||||
return (SaveOutcome.failure('Corrupted save file: ${e.message}'), null);
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:asciineverdie/src/core/model/save_data.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_integrity.dart';
|
||||
|
||||
/// Persists GameSave as JSON compressed with GZipCodec.
|
||||
/// Persists GameSave as JSON compressed with GZipCodec + HMAC-SHA256 integrity.
|
||||
///
|
||||
/// 파일 포맷: [32-byte HMAC][GZip data]
|
||||
class SaveService {
|
||||
SaveService({required this.baseDir});
|
||||
|
||||
@@ -17,14 +21,26 @@ class SaveService {
|
||||
final jsonStr = jsonEncode(save.toJson());
|
||||
final bytes = utf8.encode(jsonStr);
|
||||
final compressed = _gzip.encode(bytes);
|
||||
return file.writeAsBytes(compressed);
|
||||
// HMAC-SHA256 서명(signature) 추가
|
||||
final signed = SaveIntegrity.sign(compressed);
|
||||
return file.writeAsBytes(signed);
|
||||
}
|
||||
|
||||
Future<GameSave> load(String fileName) async {
|
||||
final path = _resolvePath(fileName);
|
||||
final file = File(path);
|
||||
final compressed = await file.readAsBytes();
|
||||
final decompressed = _gzip.decode(compressed);
|
||||
final fileBytes = await file.readAsBytes();
|
||||
|
||||
// HMAC 무결성(integrity) 검증 — 구 포맷은 경고 후 통과
|
||||
final result = SaveIntegrity.verify(fileBytes);
|
||||
if (result.isLegacy) {
|
||||
developer.log(
|
||||
'레거시(legacy) 세이브 포맷 감지: $fileName — 다음 저장 시 HMAC 자동 추가',
|
||||
name: 'SaveService',
|
||||
);
|
||||
}
|
||||
|
||||
final decompressed = _gzip.decode(result.gzipBytes);
|
||||
final jsonStr = utf8.decode(decompressed);
|
||||
final map = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
return GameSave.fromJson(map);
|
||||
|
||||
@@ -12,55 +12,56 @@ class SettingsRepository {
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
/// SharedPreferences 초기화
|
||||
Future<void> init() async {
|
||||
Future<SharedPreferences> _getPrefs() async {
|
||||
_prefs ??= await SharedPreferences.getInstance();
|
||||
return _prefs!;
|
||||
}
|
||||
|
||||
/// 언어 설정 저장
|
||||
Future<void> saveLocale(String locale) async {
|
||||
await init();
|
||||
await _prefs!.setString(_keyLocale, locale);
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setString(_keyLocale, locale);
|
||||
}
|
||||
|
||||
/// 언어 설정 불러오기
|
||||
Future<String?> loadLocale() async {
|
||||
await init();
|
||||
return _prefs!.getString(_keyLocale);
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyLocale);
|
||||
}
|
||||
|
||||
/// BGM 볼륨 저장 (0.0 ~ 1.0)
|
||||
Future<void> saveBgmVolume(double volume) async {
|
||||
await init();
|
||||
await _prefs!.setDouble(_keyBgmVolume, volume.clamp(0.0, 1.0));
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setDouble(_keyBgmVolume, volume.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
/// BGM 볼륨 불러오기 (기본값: 0.7)
|
||||
Future<double> loadBgmVolume() async {
|
||||
await init();
|
||||
return _prefs!.getDouble(_keyBgmVolume) ?? 0.7;
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getDouble(_keyBgmVolume) ?? 0.7;
|
||||
}
|
||||
|
||||
/// SFX 볼륨 저장 (0.0 ~ 1.0)
|
||||
Future<void> saveSfxVolume(double volume) async {
|
||||
await init();
|
||||
await _prefs!.setDouble(_keySfxVolume, volume.clamp(0.0, 1.0));
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setDouble(_keySfxVolume, volume.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
/// SFX 볼륨 불러오기 (기본값: 0.8)
|
||||
Future<double> loadSfxVolume() async {
|
||||
await init();
|
||||
return _prefs!.getDouble(_keySfxVolume) ?? 0.8;
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getDouble(_keySfxVolume) ?? 0.8;
|
||||
}
|
||||
|
||||
/// 애니메이션 속도 저장 (0.5 ~ 2.0, 1.0이 기본)
|
||||
Future<void> saveAnimationSpeed(double speed) async {
|
||||
await init();
|
||||
await _prefs!.setDouble(_keyAnimationSpeed, speed.clamp(0.5, 2.0));
|
||||
final prefs = await _getPrefs();
|
||||
await prefs.setDouble(_keyAnimationSpeed, speed.clamp(0.5, 2.0));
|
||||
}
|
||||
|
||||
/// 애니메이션 속도 불러오기 (기본값: 1.0)
|
||||
Future<double> loadAnimationSpeed() async {
|
||||
await init();
|
||||
return _prefs!.getDouble(_keyAnimationSpeed) ?? 1.0;
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getDouble(_keyAnimationSpeed) ?? 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/// Progress Quest 핵심 로직 모듈
|
||||
/// 게임 핵심 로직 모듈
|
||||
///
|
||||
/// 원본 Delphi 소스(Main.pas / NewGuy.pas)의 유틸리티 함수들을 포팅.
|
||||
/// 유틸리티 함수 모음.
|
||||
/// 이 파일은 분할된 모듈들을 re-export하여 기존 코드 호환성 유지.
|
||||
library;
|
||||
|
||||
// 랜덤/확률 함수
|
||||
export 'package:asciineverdie/src/core/util/pq_random.dart';
|
||||
|
||||
403
lib/src/features/arena/arena_battle_controller.dart
Normal file
403
lib/src/features/arena/arena_battle_controller.dart
Normal file
@@ -0,0 +1,403 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:asciineverdie/src/core/engine/arena_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
|
||||
|
||||
/// 아레나 전투 상태 (Arena Battle State)
|
||||
///
|
||||
/// 컨트롤러가 관리하는 전투 상태 스냅샷
|
||||
class ArenaBattleState {
|
||||
ArenaBattleState({
|
||||
required this.currentTurn,
|
||||
required this.challengerHp,
|
||||
required this.challengerHpMax,
|
||||
required this.challengerMp,
|
||||
required this.challengerMpMax,
|
||||
required this.opponentHp,
|
||||
required this.opponentHpMax,
|
||||
required this.opponentMp,
|
||||
required this.opponentMpMax,
|
||||
required this.battleLog,
|
||||
required this.isFinished,
|
||||
this.result,
|
||||
this.latestCombatEvent,
|
||||
this.currentEventIcon,
|
||||
this.currentSkillName,
|
||||
this.challengerHpChange = 0,
|
||||
this.opponentHpChange = 0,
|
||||
this.battleStartTime,
|
||||
});
|
||||
|
||||
final int currentTurn;
|
||||
final int challengerHp;
|
||||
final int challengerHpMax;
|
||||
final int challengerMp;
|
||||
final int challengerMpMax;
|
||||
final int opponentHp;
|
||||
final int opponentHpMax;
|
||||
final int opponentMp;
|
||||
final int opponentMpMax;
|
||||
final List<CombatLogEntry> battleLog;
|
||||
final bool isFinished;
|
||||
final ArenaMatchResult? result;
|
||||
final CombatEvent? latestCombatEvent;
|
||||
final CombatEventType? currentEventIcon;
|
||||
final String? currentSkillName;
|
||||
final int challengerHpChange;
|
||||
final int opponentHpChange;
|
||||
final DateTime? battleStartTime;
|
||||
}
|
||||
|
||||
/// 아레나 전투 컨트롤러 (Arena Battle Controller)
|
||||
///
|
||||
/// 전투 시뮬레이션 스트림 구독, 턴 처리, 로그 생성을 담당
|
||||
class ArenaBattleController {
|
||||
ArenaBattleController({required this.match});
|
||||
|
||||
final ArenaMatch match;
|
||||
final ArenaService _arenaService = ArenaService();
|
||||
|
||||
// 상태 (State)
|
||||
int _currentTurn = 0;
|
||||
DateTime? _battleStartTime;
|
||||
late int _challengerHp;
|
||||
late int _challengerHpMax;
|
||||
late int _challengerMp;
|
||||
late int _challengerMpMax;
|
||||
late int _opponentHp;
|
||||
late int _opponentHpMax;
|
||||
late int _opponentMp;
|
||||
late int _opponentMpMax;
|
||||
final List<CombatLogEntry> _battleLog = [];
|
||||
ArenaMatchResult? _result;
|
||||
CombatEvent? _latestCombatEvent;
|
||||
CombatEventType? _currentEventIcon;
|
||||
String? _currentSkillName;
|
||||
int _challengerHpChange = 0;
|
||||
int _opponentHpChange = 0;
|
||||
bool _isFinished = false;
|
||||
|
||||
StreamSubscription<ArenaCombatTurn>? _combatSubscription;
|
||||
Timer? _eventIconTimer;
|
||||
|
||||
/// 상태 변경 콜백 (setState 대체)
|
||||
void Function()? onStateChanged;
|
||||
|
||||
/// HP 변화 콜백 (애니메이션 트리거용)
|
||||
/// challenger: true = 도전자, false = 상대
|
||||
void Function(bool challenger)? onHpChanged;
|
||||
|
||||
/// 현재 상태 스냅샷
|
||||
ArenaBattleState get state => ArenaBattleState(
|
||||
currentTurn: _currentTurn,
|
||||
challengerHp: _challengerHp,
|
||||
challengerHpMax: _challengerHpMax,
|
||||
challengerMp: _challengerMp,
|
||||
challengerMpMax: _challengerMpMax,
|
||||
opponentHp: _opponentHp,
|
||||
opponentHpMax: _opponentHpMax,
|
||||
opponentMp: _opponentMp,
|
||||
opponentMpMax: _opponentMpMax,
|
||||
battleLog: _battleLog,
|
||||
isFinished: _isFinished,
|
||||
result: _result,
|
||||
latestCombatEvent: _latestCombatEvent,
|
||||
currentEventIcon: _currentEventIcon,
|
||||
currentSkillName: _currentSkillName,
|
||||
challengerHpChange: _challengerHpChange,
|
||||
opponentHpChange: _opponentHpChange,
|
||||
battleStartTime: _battleStartTime,
|
||||
);
|
||||
|
||||
/// HP/MP 초기화
|
||||
void initialize() {
|
||||
_challengerHpMax = match.challenger.finalStats?.hpMax ?? 100;
|
||||
_challengerHp = _challengerHpMax;
|
||||
_challengerMpMax = match.challenger.finalStats?.mpMax ?? 50;
|
||||
_challengerMp = _challengerMpMax;
|
||||
_opponentHpMax = match.opponent.finalStats?.hpMax ?? 100;
|
||||
_opponentHp = _opponentHpMax;
|
||||
_opponentMpMax = match.opponent.finalStats?.mpMax ?? 50;
|
||||
_opponentMp = _opponentMpMax;
|
||||
}
|
||||
|
||||
/// 전투 시작
|
||||
void startBattle() {
|
||||
_battleStartTime = DateTime.now();
|
||||
_combatSubscription = _arenaService
|
||||
.simulateCombat(match)
|
||||
.listen(_processTurn, onDone: _endBattle);
|
||||
}
|
||||
|
||||
/// 턴 처리 (Turn Processing)
|
||||
void _processTurn(ArenaCombatTurn turn) {
|
||||
final oldChallengerHp = _challengerHp;
|
||||
final oldOpponentHp = _opponentHp;
|
||||
|
||||
_currentTurn++;
|
||||
_challengerHp = turn.challengerHp;
|
||||
_opponentHp = turn.opponentHp;
|
||||
_challengerMp = turn.challengerMp ?? _challengerMp;
|
||||
_opponentMp = turn.opponentMp ?? _opponentMp;
|
||||
|
||||
// 도전자 HP 변화 감지
|
||||
if (oldChallengerHp != _challengerHp) {
|
||||
_challengerHpChange = _challengerHp - oldChallengerHp;
|
||||
onHpChanged?.call(true);
|
||||
}
|
||||
|
||||
// 상대 HP 변화 감지
|
||||
if (oldOpponentHp != _opponentHp) {
|
||||
_opponentHpChange = _opponentHp - oldOpponentHp;
|
||||
onHpChanged?.call(false);
|
||||
}
|
||||
|
||||
// 전투 로그 생성
|
||||
_addTurnLogs(turn);
|
||||
|
||||
// 전투 이벤트 생성 (테두리 이펙트용)
|
||||
_latestCombatEvent = _createCombatEvent(turn);
|
||||
|
||||
// 전투 이벤트 아이콘 표시
|
||||
_showEventIcon(turn);
|
||||
|
||||
onStateChanged?.call();
|
||||
}
|
||||
|
||||
/// 턴 로그 생성 (Turn Log Generation)
|
||||
void _addTurnLogs(ArenaCombatTurn turn) {
|
||||
final challengerName = match.challenger.characterName;
|
||||
final opponentName = match.opponent.characterName;
|
||||
|
||||
// 도전자 스킬 사용 로그
|
||||
if (turn.challengerSkillUsed != null) {
|
||||
_battleLog.add(
|
||||
CombatLogEntry(
|
||||
message: '$challengerName uses ${turn.challengerSkillUsed}!',
|
||||
timestamp: DateTime.now(),
|
||||
type: CombatLogType.skill,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 도전자 회복 로그
|
||||
if (turn.challengerHealAmount != null && turn.challengerHealAmount! > 0) {
|
||||
_battleLog.add(
|
||||
CombatLogEntry(
|
||||
message: '$challengerName heals ${turn.challengerHealAmount} HP!',
|
||||
timestamp: DateTime.now(),
|
||||
type: CombatLogType.heal,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 도전자 데미지 로그
|
||||
if (turn.challengerDamage != null) {
|
||||
final type = turn.isChallengerCritical
|
||||
? CombatLogType.critical
|
||||
: CombatLogType.damage;
|
||||
final critText = turn.isChallengerCritical ? ' CRITICAL!' : '';
|
||||
final skillText = turn.challengerSkillUsed != null ? '' : '';
|
||||
_battleLog.add(
|
||||
CombatLogEntry(
|
||||
message:
|
||||
'$challengerName deals ${turn.challengerDamage}'
|
||||
'$critText$skillText',
|
||||
timestamp: DateTime.now(),
|
||||
type: type,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 상대 회피/블록 이벤트
|
||||
if (turn.isOpponentEvaded) {
|
||||
_battleLog.add(
|
||||
CombatLogEntry(
|
||||
message: '$opponentName evaded!',
|
||||
timestamp: DateTime.now(),
|
||||
type: CombatLogType.evade,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (turn.isOpponentBlocked) {
|
||||
_battleLog.add(
|
||||
CombatLogEntry(
|
||||
message: '$opponentName blocked!',
|
||||
timestamp: DateTime.now(),
|
||||
type: CombatLogType.block,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 상대 스킬 사용 로그
|
||||
if (turn.opponentSkillUsed != null) {
|
||||
_battleLog.add(
|
||||
CombatLogEntry(
|
||||
message: '$opponentName uses ${turn.opponentSkillUsed}!',
|
||||
timestamp: DateTime.now(),
|
||||
type: CombatLogType.skill,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 상대 회복 로그
|
||||
if (turn.opponentHealAmount != null && turn.opponentHealAmount! > 0) {
|
||||
_battleLog.add(
|
||||
CombatLogEntry(
|
||||
message: '$opponentName heals ${turn.opponentHealAmount} HP!',
|
||||
timestamp: DateTime.now(),
|
||||
type: CombatLogType.heal,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 상대 데미지 로그
|
||||
if (turn.opponentDamage != null) {
|
||||
final type = turn.isOpponentCritical
|
||||
? CombatLogType.critical
|
||||
: CombatLogType.monsterAttack;
|
||||
final critText = turn.isOpponentCritical ? ' CRITICAL!' : '';
|
||||
_battleLog.add(
|
||||
CombatLogEntry(
|
||||
message: '$opponentName deals ${turn.opponentDamage}$critText',
|
||||
timestamp: DateTime.now(),
|
||||
type: type,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 도전자 회피/블록 이벤트
|
||||
if (turn.isChallengerEvaded) {
|
||||
_battleLog.add(
|
||||
CombatLogEntry(
|
||||
message: '$challengerName evaded!',
|
||||
timestamp: DateTime.now(),
|
||||
type: CombatLogType.evade,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (turn.isChallengerBlocked) {
|
||||
_battleLog.add(
|
||||
CombatLogEntry(
|
||||
message: '$challengerName blocked!',
|
||||
timestamp: DateTime.now(),
|
||||
type: CombatLogType.block,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 전투 이벤트 아이콘 표시 (일정 시간 후 사라짐)
|
||||
void _showEventIcon(ArenaCombatTurn turn) {
|
||||
_eventIconTimer?.cancel();
|
||||
|
||||
_currentSkillName = turn.challengerSkillUsed ?? turn.opponentSkillUsed;
|
||||
|
||||
// 이벤트 타입 결정 (우선순위: 스킬 > 크리티컬 > 블록 > 회피 > 일반공격)
|
||||
CombatEventType? eventType;
|
||||
if (_currentSkillName != null) {
|
||||
eventType = CombatEventType.playerSkill;
|
||||
} else if (turn.isChallengerCritical || turn.isOpponentCritical) {
|
||||
eventType = CombatEventType.playerAttack;
|
||||
} else if (turn.isChallengerBlocked || turn.isOpponentBlocked) {
|
||||
eventType = CombatEventType.playerBlock;
|
||||
} else if (turn.isChallengerEvaded || turn.isOpponentEvaded) {
|
||||
eventType = CombatEventType.playerEvade;
|
||||
} else if (turn.challengerDamage != null || turn.opponentDamage != null) {
|
||||
eventType = CombatEventType.playerAttack;
|
||||
}
|
||||
|
||||
_currentEventIcon = eventType;
|
||||
|
||||
// 800ms 후 아이콘 숨김
|
||||
_eventIconTimer = Timer(const Duration(milliseconds: 800), () {
|
||||
_currentEventIcon = null;
|
||||
_currentSkillName = null;
|
||||
onStateChanged?.call();
|
||||
});
|
||||
}
|
||||
|
||||
/// ArenaCombatTurn에서 CombatEvent 생성 (테두리 이펙트용)
|
||||
CombatEvent? _createCombatEvent(ArenaCombatTurn turn) {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final challengerName = match.challenger.characterName;
|
||||
final opponentName = match.opponent.characterName;
|
||||
|
||||
// 도전자 스킬 사용 (보라색 테두리)
|
||||
if (turn.challengerSkillUsed != null && turn.challengerDamage != null) {
|
||||
return CombatEvent.playerSkill(
|
||||
timestamp: timestamp,
|
||||
skillName: turn.challengerSkillUsed!,
|
||||
damage: turn.challengerDamage!,
|
||||
targetName: opponentName,
|
||||
isCritical: turn.isChallengerCritical,
|
||||
);
|
||||
}
|
||||
|
||||
// 도전자 공격 이벤트
|
||||
if (turn.challengerDamage != null) {
|
||||
return CombatEvent.playerAttack(
|
||||
timestamp: timestamp,
|
||||
damage: turn.challengerDamage!,
|
||||
targetName: opponentName,
|
||||
isCritical: turn.isChallengerCritical,
|
||||
);
|
||||
}
|
||||
|
||||
// 도전자 회복 이벤트
|
||||
if (turn.challengerHealAmount != null && turn.challengerSkillUsed != null) {
|
||||
return CombatEvent.playerHeal(
|
||||
timestamp: timestamp,
|
||||
healAmount: turn.challengerHealAmount!,
|
||||
skillName: turn.challengerSkillUsed,
|
||||
);
|
||||
}
|
||||
|
||||
// 도전자 방어 이벤트 (회피/블록)
|
||||
if (turn.isChallengerEvaded) {
|
||||
return CombatEvent.playerEvade(
|
||||
timestamp: timestamp,
|
||||
attackerName: opponentName,
|
||||
);
|
||||
}
|
||||
if (turn.isChallengerBlocked) {
|
||||
return CombatEvent.playerBlock(
|
||||
timestamp: timestamp,
|
||||
reducedDamage: turn.opponentDamage ?? 0,
|
||||
attackerName: opponentName,
|
||||
);
|
||||
}
|
||||
|
||||
// 상대 공격 이벤트
|
||||
if (turn.opponentDamage != null) {
|
||||
return CombatEvent.monsterAttack(
|
||||
timestamp: timestamp,
|
||||
damage: turn.opponentDamage!,
|
||||
attackerName: challengerName,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 전투 종료 처리
|
||||
void _endBattle() {
|
||||
_result = _arenaService.createResultFromSimulation(
|
||||
match: match,
|
||||
challengerHp: _challengerHp,
|
||||
opponentHp: _opponentHp,
|
||||
turns: _currentTurn,
|
||||
);
|
||||
|
||||
_isFinished = true;
|
||||
onStateChanged?.call();
|
||||
}
|
||||
|
||||
/// 리소스 해제
|
||||
void dispose() {
|
||||
_combatSubscription?.cancel();
|
||||
_eventIconTimer?.cancel();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart';
|
||||
import 'package:asciineverdie/src/features/arena/arena_setup_screen.dart';
|
||||
@@ -7,12 +8,6 @@ import 'package:asciineverdie/src/features/arena/widgets/arena_rank_card.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
import 'package:asciineverdie/src/shared/widgets/retro_panel.dart';
|
||||
|
||||
// 임시 문자열 (추후 l10n으로 이동)
|
||||
const _arenaTitle = 'LOCAL ARENA';
|
||||
const _arenaSubtitle = 'SELECT YOUR FIGHTER';
|
||||
const _arenaEmpty = 'Not enough heroes';
|
||||
const _arenaEmptyHint = 'Clear the game with 2+ characters';
|
||||
|
||||
/// 로컬 아레나 메인 화면
|
||||
///
|
||||
/// 순위표 표시 및 도전하기 버튼
|
||||
@@ -68,11 +63,12 @@ class _ArenaScreenState extends State<ArenaScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
return Scaffold(
|
||||
backgroundColor: RetroColors.backgroundOf(context),
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_arenaTitle,
|
||||
l10n.arenaTitle,
|
||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15),
|
||||
),
|
||||
centerTitle: true,
|
||||
@@ -101,6 +97,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
final l10n = L10n.of(context);
|
||||
return Center(
|
||||
child: RetroPanel(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -114,7 +111,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_arenaEmpty,
|
||||
l10n.arenaEmptyTitle,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
@@ -123,7 +120,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_arenaEmptyHint,
|
||||
l10n.arenaEmptyHint,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
@@ -143,7 +140,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: RetroGoldPanel(
|
||||
title: _arenaSubtitle,
|
||||
title: L10n.of(context).arenaSelectFighter,
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: ListView.builder(
|
||||
itemCount: rankedEntries.length,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/arena_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
@@ -13,11 +14,6 @@ import 'package:asciineverdie/src/features/arena/widgets/arena_idle_preview.dart
|
||||
import 'package:asciineverdie/src/features/arena/widgets/arena_rank_card.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
// 임시 문자열 (추후 l10n으로 이동)
|
||||
const _setupTitle = 'ARENA SETUP';
|
||||
const _selectCharacter = 'SELECT YOUR FIGHTER';
|
||||
const _startBattleLabel = 'START BATTLE';
|
||||
|
||||
/// 아레나 설정 화면
|
||||
///
|
||||
/// 캐릭터 선택 및 슬롯 선택
|
||||
@@ -128,11 +124,12 @@ class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
return Scaffold(
|
||||
backgroundColor: RetroColors.backgroundOf(context),
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_setupTitle,
|
||||
l10n.arenaSetupTitle,
|
||||
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15),
|
||||
),
|
||||
centerTitle: true,
|
||||
@@ -153,7 +150,7 @@ class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
_selectCharacter,
|
||||
L10n.of(context).arenaSelectFighter,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
@@ -371,7 +368,7 @@ class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_startBattleLabel,
|
||||
L10n.of(context).arenaStartBattle,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
|
||||
173
lib/src/features/arena/widgets/arena_battle_area.dart
Normal file
173
lib/src/features/arena/widgets/arena_battle_area.dart
Normal file
@@ -0,0 +1,173 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/shared/animation/race_character_frames.dart';
|
||||
import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 아레나 전투 영역 위젯 (Arena Battle Area)
|
||||
///
|
||||
/// 활성 전투 중 ASCII 애니메이션 표시, 종료 시 승자/패자 분기 처리
|
||||
class ArenaBattleArea extends StatelessWidget {
|
||||
const ArenaBattleArea({
|
||||
super.key,
|
||||
required this.match,
|
||||
required this.isFinished,
|
||||
this.result,
|
||||
this.latestCombatEvent,
|
||||
});
|
||||
|
||||
final ArenaMatch match;
|
||||
final bool isFinished;
|
||||
final ArenaMatchResult? result;
|
||||
final CombatEvent? latestCombatEvent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isFinished && result != null) {
|
||||
return _buildFinishedArea(context);
|
||||
}
|
||||
return _buildActiveArea(context);
|
||||
}
|
||||
|
||||
/// 방패 장착 여부 확인
|
||||
bool _hasShield(HallOfFameEntry entry) {
|
||||
final equipment = entry.finalEquipment;
|
||||
if (equipment == null) return false;
|
||||
return equipment.any((item) => item.slot.name == 'shield');
|
||||
}
|
||||
|
||||
/// 활성 전투 영역 (기존 AsciiAnimationCard)
|
||||
Widget _buildActiveArea(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SizedBox(
|
||||
height: 120,
|
||||
child: AsciiAnimationCard(
|
||||
taskType: TaskType.kill,
|
||||
raceId: match.challenger.raceId,
|
||||
shieldName: _hasShield(match.challenger) ? 'shield' : null,
|
||||
opponentRaceId: match.opponent.raceId,
|
||||
opponentHasShield: _hasShield(match.opponent),
|
||||
latestCombatEvent: latestCombatEvent,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 종료된 전투 영역 (승자 유지 + 패자 분해)
|
||||
Widget _buildFinishedArea(BuildContext context) {
|
||||
final isVictory = result!.isVictory;
|
||||
final winnerRaceId = isVictory
|
||||
? match.challenger.raceId
|
||||
: match.opponent.raceId;
|
||||
final loserRaceId = isVictory
|
||||
? match.opponent.raceId
|
||||
: match.challenger.raceId;
|
||||
|
||||
// 패자 캐릭터 프레임 (idle 첫 프레임)
|
||||
final loserFrameData =
|
||||
RaceCharacterFrames.get(loserRaceId) ??
|
||||
RaceCharacterFrames.defaultFrames;
|
||||
final loserLines = loserFrameData.idle.first.lines;
|
||||
|
||||
// 승자 캐릭터 프레임 (idle 첫 프레임)
|
||||
final winnerFrameData =
|
||||
RaceCharacterFrames.get(winnerRaceId) ??
|
||||
RaceCharacterFrames.defaultFrames;
|
||||
final winnerLines = winnerFrameData.idle.first.lines;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SizedBox(
|
||||
height: 120,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// 좌측: 도전자 (승자면 유지, 패자면 분해)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: isVictory
|
||||
? _buildStaticCharacter(context, winnerLines)
|
||||
: AsciiDisintegrateWidget(
|
||||
characterLines: _mirrorLines(loserLines),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 중앙 VS
|
||||
Text(
|
||||
'VS',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.goldOf(context).withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
// 우측: 상대 (승자면 유지, 패자면 분해)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: isVictory
|
||||
? AsciiDisintegrateWidget(characterLines: loserLines)
|
||||
: _buildStaticCharacter(context, _mirrorLines(winnerLines)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 정적 ASCII 캐릭터 표시
|
||||
Widget _buildStaticCharacter(BuildContext context, List<String> lines) {
|
||||
final textColor = RetroColors.textPrimaryOf(context);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: lines
|
||||
.map(
|
||||
(line) => Text(
|
||||
line,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 15,
|
||||
color: textColor,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// ASCII 문자열 미러링 (좌우 대칭)
|
||||
static List<String> _mirrorLines(List<String> lines) {
|
||||
return lines.map((line) {
|
||||
final chars = line.split('');
|
||||
return chars.reversed.map(_mirrorChar).join();
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 개별 문자 미러링
|
||||
static String _mirrorChar(String char) {
|
||||
return switch (char) {
|
||||
'/' => r'\',
|
||||
r'\' => '/',
|
||||
'(' => ')',
|
||||
')' => '(',
|
||||
'[' => ']',
|
||||
']' => '[',
|
||||
'{' => '}',
|
||||
'}' => '{',
|
||||
'<' => '>',
|
||||
'>' => '<',
|
||||
'd' => 'b',
|
||||
'b' => 'd',
|
||||
'q' => 'p',
|
||||
'p' => 'q',
|
||||
_ => char,
|
||||
};
|
||||
}
|
||||
}
|
||||
104
lib/src/features/arena/widgets/arena_combat_event_icons.dart
Normal file
104
lib/src/features/arena/widgets/arena_combat_event_icons.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||
|
||||
/// 아레나 전투 이벤트 아이콘 위젯 (Arena Combat Event Icons)
|
||||
///
|
||||
/// 스킬 사용, 크리티컬, 블록, 회피 등 특수 이벤트를 아이콘으로 표시
|
||||
class ArenaCombatEventIcons extends StatelessWidget {
|
||||
const ArenaCombatEventIcons({
|
||||
super.key,
|
||||
this.currentEventIcon,
|
||||
this.currentSkillName,
|
||||
this.latestCombatEvent,
|
||||
});
|
||||
|
||||
/// 현재 표시 중인 이벤트 아이콘 타입
|
||||
final CombatEventType? currentEventIcon;
|
||||
|
||||
/// 현재 표시 중인 스킬 이름
|
||||
final String? currentSkillName;
|
||||
|
||||
/// 최신 전투 이벤트 (크리티컬 체크용)
|
||||
final CombatEvent? latestCombatEvent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasSpecialEvent =
|
||||
currentSkillName != null ||
|
||||
latestCombatEvent?.isCritical == true ||
|
||||
currentEventIcon == CombatEventType.playerBlock ||
|
||||
currentEventIcon == CombatEventType.playerEvade ||
|
||||
currentEventIcon == CombatEventType.playerParry ||
|
||||
currentEventIcon == CombatEventType.playerSkill;
|
||||
|
||||
if (!hasSpecialEvent) {
|
||||
return const SizedBox(height: 28);
|
||||
}
|
||||
|
||||
final (icon, color) = _getEventIconData();
|
||||
|
||||
return AnimatedOpacity(
|
||||
opacity: currentEventIcon != null ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: SizedBox(
|
||||
height: 28,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 버프 아이콘 스타일 (CircularProgressIndicator)
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
value: 1.0,
|
||||
strokeWidth: 2,
|
||||
backgroundColor: Colors.grey.shade700,
|
||||
valueColor: AlwaysStoppedAnimation(color),
|
||||
),
|
||||
),
|
||||
Icon(icon, size: 12, color: color),
|
||||
],
|
||||
),
|
||||
// 스킬 이름 표시
|
||||
if (currentSkillName != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
currentSkillName!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 이벤트 타입에 따른 아이콘, 색상 반환
|
||||
(IconData, Color) _getEventIconData() {
|
||||
// 스킬 사용
|
||||
if (currentSkillName != null ||
|
||||
currentEventIcon == CombatEventType.playerSkill) {
|
||||
return (Icons.auto_fix_high, Colors.purple);
|
||||
}
|
||||
|
||||
// 크리티컬 체크
|
||||
if (latestCombatEvent?.isCritical == true) {
|
||||
return (Icons.flash_on, Colors.yellow.shade600);
|
||||
}
|
||||
|
||||
return switch (currentEventIcon) {
|
||||
CombatEventType.playerBlock => (Icons.shield, Colors.blue),
|
||||
CombatEventType.playerEvade => (Icons.directions_run, Colors.cyan),
|
||||
CombatEventType.playerParry => (Icons.sports_kabaddi, Colors.purple),
|
||||
_ => (Icons.trending_up, Colors.lightBlue),
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user