diff --git a/.github/workflows/release-note.yml b/.github/workflows/release-note.yml
index 4b838e74..87aa2f29 100644
--- a/.github/workflows/release-note.yml
+++ b/.github/workflows/release-note.yml
@@ -1,4 +1,4 @@
-name: Release Drafter
+name: Release Note Generator
on:
pull_request:
@@ -11,7 +11,7 @@ permissions:
pull-requests: read
jobs:
- update_release_draft:
+ create_release_note:
# PR이 실제로 merge되었을 때만 실행
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
@@ -24,12 +24,14 @@ jobs:
- name: Extract Xcode version info
id: xcode_version
run: |
- PROJECT_NAME="SoloDeveloperTraining" # 프로젝트 이름
- # project.pbxproj에서 MARKETING_VERSION 추출 (예: 1.0.0)
+ # 프로젝트 이름
+ PROJECT_NAME="SoloDeveloperTraining"
+
+ # MARKETING_VERSION 추출
MARKETING_VERSION=$(grep -m 1 "MARKETING_VERSION = " "${PROJECT_NAME}/${PROJECT_NAME}.xcodeproj/project.pbxproj" | sed 's/.*MARKETING_VERSION = \(.*\);/\1/' | tr -d ' ')
-
- # project.pbxproj에서 CURRENT_PROJECT_VERSION 추출 (빌드 번호, 예: 42)
- BUILD_NUMBER=$(grep -m 1 "CURRENT_PROJECT_VERSION = " "${PROJECT_NAME}/${PROJECT_NAME}.xcodeproj/project.pbxproj" | sed 's/.*MARKETING_VERSION = \(.*\);/\1/' | tr -d ' ')
+
+ # BUILD_NUMBER 추출
+ BUILD_NUMBER=$(grep -m 1 "CURRENT_PROJECT_VERSION = " "${PROJECT_NAME}/${PROJECT_NAME}.xcodeproj/project.pbxproj" | awk -F'[ ;]' '{print $3}')
echo "marketing_version=$MARKETING_VERSION" >> $GITHUB_OUTPUT
echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT
@@ -49,126 +51,149 @@ jobs:
echo "✅ Tag v${{ steps.xcode_version.outputs.marketing_version }} does not exist"
fi
- # PR 조회
- - name: Get merged PRs since last release
- # 동일한 버전이 존재하지 않을 경우만 실행
+ # main 브랜치에 포함된 커밋을 기준으로 PR 조회
+ - name: Get merged PRs in main branch
+ # 동일한 태그가 존재하지 않을 경우만 실행
if: steps.check_tag.outputs.exists == 'false'
id: get_prs
uses: actions/github-script@v7
with:
script: |
- const latestRelease = await github.rest.repos.getLatestRelease({
- owner: context.repo.owner,
- repo: context.repo.repo
- }).catch(() => null);
-
- const since = latestRelease?.data.published_at || '';
-
- const { data: pulls } = await github.rest.pulls.list({
- owner: context.repo.owner,
- repo: context.repo.repo,
- state: 'closed',
- base: 'main',
- sort: 'updated',
- direction: 'desc'
- });
-
- const mergedPRs = pulls.filter(pr => {
- if (!pr.merged_at) return false;
- if (!since) return true; // 첫 릴리즈면 모든 PR 포함
- return new Date(pr.merged_at) > new Date(since);
- });
-
- // 라벨별로 PR 분류
- const features = mergedPRs.filter(pr =>
- pr.labels.some(l => l.name === 'Feature' || l.name === 'UI' || l.name === 'Design')
- );
- const bugfixes = mergedPRs.filter(pr =>
- pr.labels.some(l => l.name === 'Fix' || l.name === 'Bug')
- );
- const maintenance = mergedPRs.filter(pr =>
- pr.labels.some(l => l.name === 'Chore' || l.name === 'Refactor' || l.name === 'Remove' || l.name === 'Docs' || l.name === 'Test')
- );
- // 제외할 라벨: Someday, Release는 릴리즈 노트에 포함하지 않음
- const others = mergedPRs.filter(pr =>
- !pr.labels.some(l => ['Feature', 'UI', 'Design', 'Fix', 'Bug', 'Chore', 'Refactor', 'Remove', 'Docs', 'Test', 'Someday', 'Release'].includes(l.name))
- );
-
- let releaseNotes = '## What\'s Changed\n\n';
-
- if (features.length > 0) {
- releaseNotes += '### 🚀 New Features\n';
- features.forEach(pr => {
- releaseNotes += `- ${pr.title} @${pr.user.login} (#${pr.number})\n`;
- });
- releaseNotes += '\n';
- }
-
- if (bugfixes.length > 0) {
- releaseNotes += '### 🐛 Bug Fixes\n';
- bugfixes.forEach(pr => {
- releaseNotes += `- ${pr.title} @${pr.user.login} (#${pr.number})\n`;
- });
- releaseNotes += '\n';
- }
-
- if (maintenance.length > 0) {
- releaseNotes += '### 🚩 Maintenance\n';
- maintenance.forEach(pr => {
- releaseNotes += `- ${pr.title} @${pr.user.login} (#${pr.number})\n`;
- });
- releaseNotes += '\n';
- }
-
- if (others.length > 0) {
- releaseNotes += '### 📝 Others\n';
- others.forEach(pr => {
- releaseNotes += `- ${pr.title} @${pr.user.login} (#${pr.number})\n`;
- });
+ try {
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const pull_number = context.payload.pull_request.number;
+
+ const prNumbers = new Set();
+
+ console.log(`🔎 Analyzing commits from merged PR #${pull_number}...`);
+
+ // 1. 현재 main으로 머지된 PR에 포함된 모든 커밋 목록을 가져옵니다.
+ const commits = await github.paginate(
+ github.rest.pulls.listCommits,
+ {
+ owner,
+ repo,
+ pull_number: pull_number
+ }
+ );
+
+ // 2. 각 커밋과 연결된 PR들을 역추적합니다.
+ for (const commit of commits) {
+ const linkedPRs = await github.rest.repos.listPullRequestsAssociatedWithCommit({
+ owner,
+ repo,
+ commit_sha: commit.sha
+ });
+
+ linkedPRs.data.forEach(pr => {
+ // 머지된 PR이고, 현재 main으로 머지된 PR 자체가 아닌 경우(하위 PR인 경우) 추가
+ if (pr.merged_at && pr.number !== pull_number) {
+ prNumbers.add(pr.number);
+ }
+ });
+ }
+
+ // 3. 현재 PR을 포함시킵니다.
+ prNumbers.add(pull_number);
+
+ console.log(`✅ Found ${prNumbers.size} unique sub-PRs.`);
+
+ const prDetails = [];
+ for (const number of Array.from(prNumbers)) {
+ const { data: pr } = await github.rest.pulls.get({
+ owner,
+ repo,
+ pull_number: number
+ });
+ prDetails.push(pr);
+ }
+
+ prDetails.sort((a, b) => new Date(b.merged_at) - new Date(a.merged_at));
+
+ // 4. PR 라벨에 따라 카테고라이징 합니다.
+ const features = prDetails.filter(pr => pr.labels.some(l => ['Feature','UI','Design'].includes(l.name)));
+ const bugfixes = prDetails.filter(pr => pr.labels.some(l => ['Fix','Bug'].includes(l.name)));
+ const maintenance = prDetails.filter(pr => pr.labels.some(l => ['Chore','Refactor','Remove','Docs','Test'].includes(l.name)));
+ const others = prDetails.filter(pr => !pr.labels.some(l => ['Feature','UI','Design','Fix','Bug','Chore','Refactor','Remove','Docs','Test','Someday','Release'].includes(l.name)));
+
+ console.log(`📊 PR Breakdown - Features: ${features.length}, Fixes: ${bugfixes.length}, Maint: ${maintenance.length}, Others: ${others.length}`);
+
+ // 5. 문서 내용을 추가합니다.
+ let releaseNotes = '## What\'s Changed\n\n';
+ const formatPR = (pr) => `- ${pr.title} @${pr.user.login} ([#${pr.number}](${pr.html_url}))\n`;
+
+ if (features.length) releaseNotes += `### 🚀 New Features\n${features.map(formatPR).join('')}\n`;
+ if (bugfixes.length) releaseNotes += `### 🐛 Bug Fixes\n${bugfixes.map(formatPR).join('')}\n`;
+ if (maintenance.length) releaseNotes += `### 🚩 Maintenance\n${maintenance.map(formatPR).join('')}\n`;
+ if (others.length) releaseNotes += `### 📝 Others\n${others.map(formatPR).join('')}\n`;
+
+ return releaseNotes;
+
+ } catch (error) {
+ console.error('❌ Error:', error.message);
+ core.setFailed(error.message);
+ throw error;
}
-
- return releaseNotes;
-
- - name: Create or Update Release Draft
+
+ # 릴리즈 생성 및 업데이트
+ - name: Create or Update Release
if: steps.check_tag.outputs.exists == 'false'
uses: actions/github-script@v7
with:
script: |
- const marketingVersion = '${{ steps.xcode_version.outputs.marketing_version }}';
- const buildNumber = '${{ steps.xcode_version.outputs.build_number }}';
- const tagName = `v${marketingVersion}`;
- const releaseNotes = ${{ steps.get_prs.outputs.result }};
-
- // 릴리즈 노트에 버전 정보 추가
- const fullReleaseNotes = `**Version**: ${marketingVersion}\n**Build**: ${buildNumber}\n\n${releaseNotes}`;
-
- // 기존 draft 릴리즈 찾기
- const { data: releases } = await github.rest.repos.listReleases({
- owner: context.repo.owner,
- repo: context.repo.repo
- });
-
- const existingDraft = releases.find(r => r.draft && r.tag_name === tagName);
-
- if (existingDraft) {
- // 업데이트
- await github.rest.repos.updateRelease({
+ try {
+ const marketingVersion = '${{ steps.xcode_version.outputs.marketing_version }}';
+ const buildNumber = '${{ steps.xcode_version.outputs.build_number }}';
+ const tagName = `v${marketingVersion}`;
+ const releaseNotes = ${{ steps.get_prs.outputs.result }};
+
+ if (!marketingVersion || marketingVersion === '') {
+ throw new Error('Marketing version is empty or invalid');
+ }
+ if (!buildNumber || buildNumber === '') {
+ throw new Error('Build number is empty or invalid');
+ }
+
+ console.log(`📦 Creating/updating release: ${tagName}`);
+ console.log(`📱 Version: ${marketingVersion}`);
+ console.log(`🔢 Build: ${buildNumber}`);
+
+ const fullReleaseNotes = `**Version**: ${marketingVersion}\n**Build**: ${buildNumber}\n\n${releaseNotes}`;
+
+ const { data: releases } = await github.rest.repos.listReleases({
owner: context.repo.owner,
- repo: context.repo.repo,
- release_id: existingDraft.id,
- body: fullReleaseNotes
+ repo: context.repo.repo
});
- console.log(`✅ Updated draft release: ${tagName} (Build: ${buildNumber})`);
- } else {
- // 새로 생성
- await github.rest.repos.createRelease({
- owner: context.repo.owner,
- repo: context.repo.repo,
- tag_name: tagName,
- name: `v${marketingVersion}`,
- body: fullReleaseNotes,
- draft: false,
- prerelease: false
- });
- console.log(`✅ Created draft release: ${tagName} (Build: ${buildNumber})`);
- }
+
+ const existingRelease = releases.find(r => r.tag_name === tagName);
+
+ if (existingRelease) {
+ console.log(`🔄 Updating existing release: ${tagName}`);
+ await github.rest.repos.updateRelease({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ release_id: existingRelease.id,
+ body: fullReleaseNotes
+ });
+ console.log(`✅ Updated release: ${tagName} (Build: ${buildNumber})`);
+ } else {
+ console.log(`🆕 Creating new release: ${tagName}`);
+ const release = await github.rest.repos.createRelease({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ tag_name: tagName,
+ name: `v${marketingVersion}`,
+ body: fullReleaseNotes,
+ draft: false,
+ prerelease: false
+ });
+ console.log(`✅ Created release: ${tagName} (Build: ${buildNumber})`);
+ console.log(`🔗 Release URL: ${release.data.html_url}`);
+ }
+
+ } catch (error) {
+ console.error('❌ Error creating/updating release:', error.message);
+ core.setFailed(`Failed to create/update release: ${error.message}`);
+ throw error;
+ }
\ No newline at end of file
diff --git a/README.md b/README.md
index 1d639ea6..461c3850 100644
--- a/README.md
+++ b/README.md
@@ -5,96 +5,122 @@
| 이름 | Sophia 김선재 | Oliver 김성훈 | Raven 서준영 | Edward 최범수 |
|:----:|:------:|:------:|:------:|:------:|
| 캠퍼 ID | S004 | S005 | S016 | S037 |
-| 사진 |
|
|
|
|
-| MBTI | ISTJ | ENFJ | ISTP | INTP |
-
-## Wiki
-- [Hmm wiki](https://github.com/boostcampwm2025/iOS01-Hmm/wiki)
-
-## 그룹 프로젝트 목표
-- 일하지 않고 추억 만들기
- - '놀이'처럼 생각하기
- - 재미를 잃지 않기
-- 배포 가능한 수준의 기능 개발 완료하기
- - App 다운로드 100회 달성하기
- - 사용자 피드백 받아보기
-
-
-
-## 주간 일정
-### 1주차 (기획 주간)
-
-
-## 개발 규칙
-- PR 올리면 슬랙에 리마인드 하기 / 깃헙 웹훅 연결 (pr)
-- 리뷰 시간
-- 스크럼 전까지 최소한 읽거나 리뷰 완료하기
-- 브랜치 관리
- - 마일스톤 단위로 main ← develop Merge
- - main : 데모/배포용 브랜치
- - dev: 개발 통합 브랜치
- - 개인 작업 브랜치: [label-prefix]/#이슈번호-설명설명
-- Merge 규칙
- - Main Branch Merge Ruleset
- - 리뷰어 4명 중 2명 이상 Approve 시 머지 권한 활성화
- - force push 제한
- - Dev Branch Merge Ruleset
-- Automatic Copilot code review 활용 시도
-- 리뷰 봇 추가 예정
-
-## 그라운드 룰
-### 🌈 생활 수칙
-- 연락이 1시간 이상 안될 경우 미리 공지하기(고정 일정 제외)
-- 문제를 겪고 있다면, 혼자 오래 고민하지 않고 공유하기
- - 혼자 3시간정도는 고민해보기
- - 고민 과정과, 질문을 미리 정리해서 공유하기
- - 슬랙에서 가능한 사람과 바로바로 공유하고, 결정 사항은 문서화 해두고 모든 팀원과 공유하기
- - 페어 프로그래밍 방식으로 해결
-- 마음이 미어지면 꼭 말하기 (이거 재밌어요)
-- 할 일 다 하고 놀기!
-- 5분 이상 지각하면 iOS 채널에 ‘저는 오늘 스크럼/회의에 지각했습니다. 다음부터는 팀원들과의 약속을 꼭 지키겠습니다.’ 올리기.
-- 팀원들의 실수도 내 실수처럼 생각하기
-- 내 코드는 PR을 올린 순간 내놓은 자식으로 생각하기
-- 질문을 어려워하지 않기
-- 영어이름 안부르면 “Slack에 저 @@@(본인 영어이름)은 팀원의 이름을 기억하지 못하고 다른 이름을 부른 바보입니다.” 일 경고 3경고 아웃
-- 칭찬 절~대 아끼지 않기
-- 싱크 맞추기
-- 담당한 역할 열심히 수행하기
-
-### 💥 회의 규칙
-- 50분 진행 10분 휴식의 반복으로 회의를 진행합니다.
-- 회의는 Slack을 활용하여 진행합니다.
-- 1주차 최종 의사 결정권자 -> **Raven**
-- 매주 데모 발표자는 돌아가면서 진행
-- 최종 데모 발표자는 **투표**로 선정
-- 사회자) 주간 계획 수립 시간에 핀볼로 정하기
-- 본인이 그 날의 서기를 지정한다.
-- 서기는 화면 공유를 하며 회의록을 작성하고, 사회자는 네비게이터 역할을 맡는다.
-
-## 기획 링크
-- [figjam](https://www.figma.com/board/Ekedk3nwZ508zvmKdvFu9e/%ED%85%8C%EC%98%A4%EC%9D%98-%EC%8A%A4%ED%94%84%EB%A6%B0%ED%8A%B8-%ED%85%9C%ED%94%8C%EB%A6%BF--Copy---Copy-?node-id=0-1&p=f&t=htJDoHWZurdaduXf-0)
-
-## PR 라벨 & 커밋 컨벤션
-- 커밋 메시지 명세: `Label: 설명설명설명`
-- PR 라벨
-
-
-## 이슈, PR 템플릿
-- 이슈 템플릿
+| 사진 |
|
|
|
|
+| 역할 | 팀원 | 🤴🏻 팀장 | 팀원 | 팀원 |
+
+# 🎮 개발자 키우기
+
+
+# 📝 개요
+
+> `개발자 키우기`는 백수에서 시작해 월드클래스 iOS 개발자로 성장하는 시뮬레이션 게임입니다.
+>
+
+본 프로젝트는 단순한 탭 반복 위주의 방치형 게임에서 벗어나,
+iOS 기기 고유의 입력 방식과 물리 시스템을 적극 활용한 게임 경험을 목표로 기획되었습니다.
+사용자는 화면 터치뿐만 아니라 기기의 기울기를 직접 조작하며 다양한 미니게임을 플레이하게 됩니다.
+
+게임의 핵심 루프는 **`미니게임 → 재산 획득 → 스킬/아이템/부동산 강화 → 커리어 성장 및 미션 보상`** 으로 구성되어 있으며,
+플레이가 누적될수록 더 효율적인 성장 전략을 설계할 수 있도록 설계되었습니다.
+이를 통해 단순한 반복이 아닌, 선택과 전략이 개입되는 성장 구조를 제공합니다.
+
+기술적으로는 `CoreMotion`을 활용한 기기 기울기 입력 처리와
+`SpriteKit` 물리 엔진을 활용한 오브젝트 상호작용을 통해
+iOS 기기 특유의 ‘손맛’과 직관적인 조작감을 구현했습니다.
+
+또한 `SwiftUI` 기반 UI 구성과 `Actor`를 활용한 동시성 제어를 통해
+게임 로직과 상태 관리를 명확히 분리하고, 안정적이고 부드러운 플레이 경험을 제공하는 것을 목표로 했습니다.
+
+# 🔨 실행 방법 / 최소 지원 버전
+```
+타깃을 SoloDeveloperTraining으로 설정 후 빌드합니다.
+```
```
-# 인수조건
-- [ ] 조건
+Minimum Deployment Target: iOS 17.0
```
-- PR 템플릿
+# ⚔️ 사용 기술 스택
+| 구분 | 스택 |
+|---|---|
+| **Language** | Swift 6.0 |
+| **UI** | SwiftUI, Observable |
+| **Framework** | CoreMotion, SpriteKit |
+| **Async** | Swift Concurrency |
+| **Tools** | SwiftLint |
+| **CI/CD** | Github Actions |
+
+# 🦿 프로젝트 구조
+```
+SoloDeveloperTraining/
+ ├── Prototype/ # 프로토타입 프로젝트
+ └── SoloDeveloperTraining/ # 메인 프로젝트
+ ├── SoloDeveloperTraining.xcodeproj
+ └── SoloDeveloperTraining/
+ │
+ ├── App/ # 앱 진입점 (AppDelegate, SceneDelegate 등)
+ │
+ ├── DesignSystem/ # 디자인 시스템
+ │
+ ├── Extensions/ # Swift 확장 기능
+ │
+ ├── GameCore/ # 게임 핵심 로직
+ │ └── Models/
+ │ ├── Games/ # 미니게임
+ │ ├── Items/ # 아이템 모델
+ │ ├── Storages/ # 저장소 모델
+ │ ├── Systems/ # 게임 시스템
+ │ └── User/ # 유저 관련
+ │
+ ├── Production/ # 프로덕션 코드
+ │ ├── Data/ # 데이터 레이어
+ │ ├── Error/ # 에러 처리
+ │ ├── FeedbackSystem/ # 피드백 시스템 (햅틱, 사운드 등)
+ │ ├── Presentation/ # UI 레이어
+ │ └── Utility/ # 유틸리티 함수들
+ │
+ ├── Development/ # 개발용 코드
+ │ └── Presentation/ # 개발용 UI
+ │
+ └── Resources/ # 리소스 파일
+ ├── Assets.xcassets/ # 이미지, 컬러 에셋
+ ├── Audio/ # 오디오 파일
+ └── Fonts/ # 폰트 파일
```
-## 연관된 이슈
-- closed #이슈번호
+# 🚀 주요 기능
-## 작업 내용 및 고민 내용
+## 1. 커리어 성장 시스템
+- **9단계 커리어 등급**
+ - **`백수 → 노트북 보유자 → 개발자 지망생 → ... → 월드클래스 개발자`**
+- **누적 재산 기반 자동 승급**: 플레이하며 자연스럽게 성장하는 시스템입니다.
+- **등급별 콘텐츠 해금**: 새로운 미니게임 모드와 강화 콘텐츠를 해금할 수 있습니다.
-## 스크린샷
+## 2. 4가지 미니게임
+| 코드짜기 | 언어 맞추기 | 버그 피하기 | 데이터 쌓기 |
+| --- | --- | --- | --- |
+|
|
|
|
|
+| 반복적인 화면 터치(탭)를 통해 재산을 획득할 수 있습니다. | 올바른 언어 아이콘을 매칭 터치하여 재산을 획득할 수 있습니다. | CoreMotion 자이로 센서를 활용하여 기기 기울여 재산을 획득할 수 있습니다. | SpriteKit 물리 엔진을 기반으로 타이밍에 맞춰 터치하여 재산을 획득할 수 있습니다. |
-## 리뷰 요구사항
-```
+## 3. 피버 시스템
+- **3단계 피버 게이지**: 0~300%까지 노란색 → 주황색 → 빨간색으로 시각화 했습니다.
+- **단계별 배율**: 100% 도달 시 x배, 200% 도달 시 y배, 300% 도달 시 z배 획득 할 수 있습니다.
+- **게이지 변화**: 액션 성공 시 증가하고, n초마다 자동 감소합니다.
+
+| 0단계 | 1단계 | 2단계 | 3단계 |
+| --- | --- | --- | --- |
+|
|
|
|
|
+
+
+## 4. 경제 시스템
+
+| 스킬| 아이템 | 부동산 |
+| --- | --- | --- |
+|
|
|
|
+| - 업무 4개 모드마다 초급/중급/고급의 스킬이 존재합니다.
- 레벨이 올라갈수록 각 업무의 액션 재산이 증가합니다. | - 커피, 박하스로 일시적 버프 효과를 획득합니다.
- 키보드, 마우스, 모니터, 의자 각각의 8등급의 강화 시스템이 존재합니다.
- 등급이 높아질수록 강화 성공 확률이 감소합니다. | - 길바닥 → 반지하 → … → 펜트하우스의 등급이 존재합니다.
- 배경을 변경할 수 있고, 부동산은 하나만 소유 가능합니다.|
+
+
+## 5. 부가 콘텐츠
+| 퀴즈 | 미션 | 튜토리얼 | 설정 |
+| --- | --- | --- | --- |
+|
|
|
|
|
+| 개발 밈과 관련된 퀴즈로 다이아 보상을 획득할 수 있습니다. | 다양한 목표 달성으로 지속적인 플레이를 보장하며 보상을 획득할 수 있습니다. | 게임 시스템의 기본적인 학습을 할 수 있습니다. | 사운드, 효과음, 햅틱에 대한 설정을 조절할 수 있습니다. |
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining.xcodeproj/project.pbxproj b/SoloDeveloperTraining/SoloDeveloperTraining.xcodeproj/project.pbxproj
index 0c77b861..b99869b4 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining.xcodeproj/project.pbxproj
+++ b/SoloDeveloperTraining/SoloDeveloperTraining.xcodeproj/project.pbxproj
@@ -6,9 +6,28 @@
objectVersion = 77;
objects = {
+/* Begin PBXContainerItemProxy section */
+ BCFC853D2F31FED800447A9A /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 08267F232F0D06BC005A0066 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 08267F2A2F0D06BC005A0066;
+ remoteInfo = SoloDeveloperTraining;
+ };
+ BCFC85502F32022C00447A9A /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 08267F232F0D06BC005A0066 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 08267F2A2F0D06BC005A0066;
+ remoteInfo = SoloDeveloperTraining;
+ };
+/* End PBXContainerItemProxy section */
+
/* Begin PBXFileReference section */
08267F2B2F0D06BC005A0066 /* SoloDeveloperTraining.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SoloDeveloperTraining.app; sourceTree = BUILT_PRODUCTS_DIR; };
2896AE422F100CD600D38732 /* SoloDeveloperTraining-Dev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "SoloDeveloperTraining-Dev.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+ BCFC85392F31FED800447A9A /* SoloDeveloperTrainingTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SoloDeveloperTrainingTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ BCFC854A2F32022C00447A9A /* SoloDeveloperTrainingUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SoloDeveloperTrainingUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -72,6 +91,16 @@
path = SoloDeveloperTraining;
sourceTree = "";
};
+ BCFC853A2F31FED800447A9A /* SoloDeveloperTrainingTests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = SoloDeveloperTrainingTests;
+ sourceTree = "";
+ };
+ BCFC854B2F32022C00447A9A /* SoloDeveloperTrainingUITests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = SoloDeveloperTrainingUITests;
+ sourceTree = "";
+ };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -89,6 +118,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ BCFC85362F31FED800447A9A /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ BCFC85472F32022C00447A9A /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -96,6 +139,8 @@
isa = PBXGroup;
children = (
08267F2D2F0D06BC005A0066 /* SoloDeveloperTraining */,
+ BCFC853A2F31FED800447A9A /* SoloDeveloperTrainingTests */,
+ BCFC854B2F32022C00447A9A /* SoloDeveloperTrainingUITests */,
08267F2C2F0D06BC005A0066 /* Products */,
);
sourceTree = "";
@@ -105,6 +150,8 @@
children = (
08267F2B2F0D06BC005A0066 /* SoloDeveloperTraining.app */,
2896AE422F100CD600D38732 /* SoloDeveloperTraining-Dev.app */,
+ BCFC85392F31FED800447A9A /* SoloDeveloperTrainingTests.xctest */,
+ BCFC854A2F32022C00447A9A /* SoloDeveloperTrainingUITests.xctest */,
);
name = Products;
sourceTree = "";
@@ -158,6 +205,52 @@
productReference = 2896AE422F100CD600D38732 /* SoloDeveloperTraining-Dev.app */;
productType = "com.apple.product-type.application";
};
+ BCFC85382F31FED800447A9A /* SoloDeveloperTrainingTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = BCFC853F2F31FED800447A9A /* Build configuration list for PBXNativeTarget "SoloDeveloperTrainingTests" */;
+ buildPhases = (
+ BCFC85352F31FED800447A9A /* Sources */,
+ BCFC85362F31FED800447A9A /* Frameworks */,
+ BCFC85372F31FED800447A9A /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ BCFC853E2F31FED800447A9A /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ BCFC853A2F31FED800447A9A /* SoloDeveloperTrainingTests */,
+ );
+ name = SoloDeveloperTrainingTests;
+ packageProductDependencies = (
+ );
+ productName = SoloDeveloperTrainingTests;
+ productReference = BCFC85392F31FED800447A9A /* SoloDeveloperTrainingTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ BCFC85492F32022C00447A9A /* SoloDeveloperTrainingUITests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = BCFC85522F32022C00447A9A /* Build configuration list for PBXNativeTarget "SoloDeveloperTrainingUITests" */;
+ buildPhases = (
+ BCFC85462F32022C00447A9A /* Sources */,
+ BCFC85472F32022C00447A9A /* Frameworks */,
+ BCFC85482F32022C00447A9A /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ BCFC85512F32022C00447A9A /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ BCFC854B2F32022C00447A9A /* SoloDeveloperTrainingUITests */,
+ );
+ name = SoloDeveloperTrainingUITests;
+ packageProductDependencies = (
+ );
+ productName = SoloDeveloperTrainingUITests;
+ productReference = BCFC854A2F32022C00447A9A /* SoloDeveloperTrainingUITests.xctest */;
+ productType = "com.apple.product-type.bundle.ui-testing";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -165,12 +258,20 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
- LastSwiftUpdateCheck = 2610;
+ LastSwiftUpdateCheck = 2620;
LastUpgradeCheck = 2610;
TargetAttributes = {
08267F2A2F0D06BC005A0066 = {
CreatedOnToolsVersion = 26.1.1;
};
+ BCFC85382F31FED800447A9A = {
+ CreatedOnToolsVersion = 26.2;
+ TestTargetID = 08267F2A2F0D06BC005A0066;
+ };
+ BCFC85492F32022C00447A9A = {
+ CreatedOnToolsVersion = 26.2;
+ TestTargetID = 08267F2A2F0D06BC005A0066;
+ };
};
};
buildConfigurationList = 08267F262F0D06BC005A0066 /* Build configuration list for PBXProject "SoloDeveloperTraining" */;
@@ -189,6 +290,8 @@
targets = (
08267F2A2F0D06BC005A0066 /* SoloDeveloperTraining */,
2896AE3A2F100CD600D38732 /* SoloDeveloperTraining-Dev */,
+ BCFC85382F31FED800447A9A /* SoloDeveloperTrainingTests */,
+ BCFC85492F32022C00447A9A /* SoloDeveloperTrainingUITests */,
);
};
/* End PBXProject section */
@@ -208,6 +311,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ BCFC85372F31FED800447A9A /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ BCFC85482F32022C00447A9A /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -264,8 +381,35 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ BCFC85352F31FED800447A9A /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ BCFC85462F32022C00447A9A /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
+/* Begin PBXTargetDependency section */
+ BCFC853E2F31FED800447A9A /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 08267F2A2F0D06BC005A0066 /* SoloDeveloperTraining */;
+ targetProxy = BCFC853D2F31FED800447A9A /* PBXContainerItemProxy */;
+ };
+ BCFC85512F32022C00447A9A /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 08267F2A2F0D06BC005A0066 /* SoloDeveloperTraining */;
+ targetProxy = BCFC85502F32022C00447A9A /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
/* Begin XCBuildConfiguration section */
08267F342F0D06BE005A0066 /* Debug */ = {
isa = XCBuildConfiguration;
@@ -415,7 +559,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.1.0;
+ MARKETING_VERSION = 1.1.1;
PRODUCT_BUNDLE_IDENTIFIER = kr.codesquad.boostcamp10.SoloDeveloperTraining;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -457,7 +601,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.1.0;
+ MARKETING_VERSION = 1.1.1;
PRODUCT_BUNDLE_IDENTIFIER = kr.codesquad.boostcamp10.SoloDeveloperTraining;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -560,6 +704,92 @@
};
name = Release;
};
+ BCFC85402F31FED800447A9A /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = B3PWYBKFUK;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = sunghun.SoloDeveloperTrainingTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SoloDeveloperTraining.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SoloDeveloperTraining";
+ };
+ name = Debug;
+ };
+ BCFC85412F31FED800447A9A /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = B3PWYBKFUK;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = sunghun.SoloDeveloperTrainingTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SoloDeveloperTraining.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SoloDeveloperTraining";
+ };
+ name = Release;
+ };
+ BCFC85532F32022C00447A9A /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = B3PWYBKFUK;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = sunghun.SoloDeveloperTrainingUITests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = SoloDeveloperTraining;
+ };
+ name = Debug;
+ };
+ BCFC85542F32022C00447A9A /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = B3PWYBKFUK;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = sunghun.SoloDeveloperTrainingUITests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = SoloDeveloperTraining;
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -590,6 +820,24 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ BCFC853F2F31FED800447A9A /* Build configuration list for PBXNativeTarget "SoloDeveloperTrainingTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ BCFC85402F31FED800447A9A /* Debug */,
+ BCFC85412F31FED800447A9A /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ BCFC85522F32022C00447A9A /* Build configuration list for PBXNativeTarget "SoloDeveloperTrainingUITests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ BCFC85532F32022C00447A9A /* Debug */,
+ BCFC85542F32022C00447A9A /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
};
rootObject = 08267F232F0D06BC005A0066 /* Project object */;
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining.xcodeproj/xcshareddata/xcschemes/SoloDeveloperTraining.xcscheme b/SoloDeveloperTraining/SoloDeveloperTraining.xcodeproj/xcshareddata/xcschemes/SoloDeveloperTraining.xcscheme
new file mode 100644
index 00000000..e8192f92
--- /dev/null
+++ b/SoloDeveloperTraining/SoloDeveloperTraining.xcodeproj/xcshareddata/xcschemes/SoloDeveloperTraining.xcscheme
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining.xcodeproj/xcshareddata/xcschemes/SoloDeveloperTrainingTests.xcscheme b/SoloDeveloperTraining/SoloDeveloperTraining.xcodeproj/xcshareddata/xcschemes/SoloDeveloperTrainingTests.xcscheme
new file mode 100644
index 00000000..89673d20
--- /dev/null
+++ b/SoloDeveloperTraining/SoloDeveloperTraining.xcodeproj/xcshareddata/xcschemes/SoloDeveloperTrainingTests.xcscheme
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining.xcodeproj/xcshareddata/xcschemes/SoloDeveloperTrainingUITests.xcscheme b/SoloDeveloperTraining/SoloDeveloperTraining.xcodeproj/xcshareddata/xcschemes/SoloDeveloperTrainingUITests.xcscheme
new file mode 100644
index 00000000..1e3b2199
--- /dev/null
+++ b/SoloDeveloperTraining/SoloDeveloperTraining.xcodeproj/xcshareddata/xcschemes/SoloDeveloperTrainingUITests.xcscheme
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/ItemRow.swift b/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/ItemRow.swift
index 13d0e218..18cb7a7d 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/ItemRow.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/ItemRow.swift
@@ -11,7 +11,7 @@ private enum Constant {
static let imageSize: CGSize = .init(width: 38, height: 38)
static let cornerRadius: CGFloat = 4
- static let priceButtonWidth: CGFloat = 89
+ static let priceButtonWidth: CGFloat = 95
enum Spacing {
static let horizontal: CGFloat = 8
@@ -32,6 +32,7 @@ struct ItemRow: View {
let cost: Cost
let state: ItemState
let action: () -> Void
+ let onLongPressAction: (() -> Bool)?
init(
title: String,
@@ -39,7 +40,8 @@ struct ItemRow: View {
imageName: String,
cost: Cost,
state: ItemState,
- action: @escaping () -> Void
+ action: @escaping () -> Void,
+ onLongPressAction: (() -> Bool)? = nil
) {
self.title = title
self.description = description
@@ -47,6 +49,7 @@ struct ItemRow: View {
self.cost = cost
self.state = state
self.action = action
+ self.onLongPressAction = onLongPressAction
}
var body: some View {
@@ -71,7 +74,8 @@ struct ItemRow: View {
state: state,
axis: .horizontal,
width: Constant.priceButtonWidth,
- action: action
+ action: action,
+ onLongPressRepeat: onLongPressAction
)
}
.padding(.horizontal, Constant.Padding.horizontal)
@@ -85,7 +89,7 @@ struct ItemRow: View {
title: "강화 / 아이템 이름 이름 이름",
description: "항목 설명 설명 설명",
imageName: "housing_street",
- cost: .init(gold: 1_000_000),
+ cost: .init(gold: 1_000_0000000000, diamond: 99),
state: .available
) {
print("Tapped")
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/MissionCardButton.swift b/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/MissionCardButton.swift
index ce37fc9b..a06a6947 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/MissionCardButton.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/MissionCardButton.swift
@@ -80,7 +80,8 @@ extension MissionCardButton {
case .claimed:
return Constant.Title.claimed
case .inProgress(let currentValue, let totalValue):
- return "\((Double(currentValue) / Double(totalValue) * 100).formatted())%"
+ let percent = Double(currentValue) / Double(totalValue) * 100
+ return String(format: "%.2f%%", percent)
}
}
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/Popup.swift b/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/Popup.swift
index c4a2617f..f3448ef9 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/Popup.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/Popup.swift
@@ -10,6 +10,7 @@ import SwiftUI
private enum Constant {
static let cornerRadius: CGFloat = 8
static let lineWidth: CGFloat = 2
+ static let contentVerticalSpacing: CGFloat = 11
enum Padding {
static let titleTop: CGFloat = 20
@@ -56,7 +57,7 @@ struct Popup: View {
}
var body: some View {
- VStack(spacing: 0) {
+ VStack(spacing: Constant.contentVerticalSpacing) {
Text(title)
.textStyle(.title3)
.multilineTextAlignment(.center)
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/PriceButton.swift b/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/PriceButton.swift
index dac10db1..629fb551 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/PriceButton.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/DesignSystem/Components/PriceButton.swift
@@ -32,25 +32,30 @@ private enum Constant {
struct PriceButton: View {
- @State private var isPressed: Bool = false
+ @GestureState private var isPressed: Bool = false
+ @State private var isLongPressing: Bool = false
+
let cost: Cost
let state: ItemState
let axis: Axis
let width: CGFloat?
let action: () -> Void
+ let onLongPressRepeat: (() -> Bool)?
init(
cost: Cost,
state: ItemState,
axis: Axis,
width: CGFloat? = nil,
- action: @escaping () -> Void
+ action: @escaping () -> Void,
+ onLongPressRepeat: (() -> Bool)? = nil
) {
self.cost = cost
self.state = state
self.axis = axis
self.width = width
self.action = action
+ self.onLongPressRepeat = onLongPressRepeat
}
private var isDisabled: Bool {
@@ -75,7 +80,16 @@ struct PriceButton: View {
)
.animation(.none, value: isDisabled)
.contentShape(Rectangle())
+ .longPressRepeat(
+ isLongPressing: $isLongPressing,
+ isDisabled: isDisabled,
+ onLongPressRepeat: onLongPressRepeat
+ )
.onTapGesture {
+ if isLongPressing {
+ isLongPressing = false
+ return
+ }
if !isDisabled {
SoundService.shared.trigger(.buttonTap)
action()
@@ -83,14 +97,11 @@ struct PriceButton: View {
}
.simultaneousGesture(
DragGesture(minimumDistance: 0)
- .onChanged { _ in
- if !isDisabled && !isPressed {
- isPressed = true
+ .updating($isPressed) { _, state, _ in
+ if !isDisabled {
+ state = true
}
}
- .onEnded { _ in
- isPressed = false
- }
)
.animation(.none, value: isPressed)
}
@@ -110,11 +121,11 @@ struct PriceButton: View {
}
.frame(width: width ?? .none, height: Constant.Layout.buttonHeight)
.padding(.horizontal, Constant.Layout.horizontalPadding)
- .background(state == .locked ? .gray300 : .orange500)
+ .background(isDisabled ? .gray300 : .orange500)
.clipShape(RoundedRectangle(cornerRadius: Constant.Layout.cornerRadius))
.offset(
- x: isPressed ? Constant.Shadow.offsetX : 0,
- y: isPressed ? Constant.Shadow.offsetY : 0
+ x: (isPressed && !isDisabled) ? Constant.Shadow.offsetX : 0,
+ y: (isPressed && !isDisabled) ? Constant.Shadow.offsetY : 0
)
.animation(.none, value: cost)
.animation(.none, value: state)
@@ -123,7 +134,12 @@ struct PriceButton: View {
@ViewBuilder
var contentViews: some View {
HStack {
- if cost.gold > 0 {
+ if state == .reachedMax {
+ Text("Max")
+ .textStyle(.caption)
+ .foregroundStyle(.white)
+ }
+ else if cost.gold > 0 {
CurrencyLabel(
axis: .horizontal,
icon: .gold,
@@ -131,8 +147,9 @@ struct PriceButton: View {
value: cost.gold
)
.foregroundStyle(.white)
+ .fixedSize()
}
- if cost.diamond > 0 {
+ else if cost.diamond > 0 {
CurrencyLabel(
axis: .horizontal,
icon: .diamond,
@@ -140,8 +157,13 @@ struct PriceButton: View {
value: cost.diamond
)
.foregroundStyle(.white)
+ .fixedSize()
}
}
+ .fixedSize()
+ .drawingGroup()
+ .minimumScaleFactor(0.7)
+ .lineLimit(1)
}
var disabledOverlay: some View {
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Devlopment/Presentation/DodgeGameTestView.swift b/SoloDeveloperTraining/SoloDeveloperTraining/Devlopment/Presentation/DodgeGameTestView.swift
index 8b71d85f..84a6657e 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/Devlopment/Presentation/DodgeGameTestView.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/Devlopment/Presentation/DodgeGameTestView.swift
@@ -263,15 +263,15 @@ struct DodgeGameTestView: View {
}
private func updateGold() async {
- let gold = await game.user.wallet.gold
+ let gold = game.user.wallet.gold
await MainActor.run {
currentGold = gold
}
}
private func updateItemCounts() async {
- let coffee = await game.user.inventory.count(.coffee) ?? 0
- let energyDrink = await game.user.inventory.count(.energyDrink) ?? 0
+ let coffee = game.user.inventory.count(.coffee) ?? 0
+ let energyDrink = game.user.inventory.count(.energyDrink) ?? 0
await MainActor.run {
coffeeCount = coffee
energyDrinkCount = energyDrink
@@ -280,7 +280,7 @@ struct DodgeGameTestView: View {
private func useCoffee() {
Task {
- let success = await game.user.inventory.drink(.coffee)
+ let success = game.user.inventory.drink(.coffee)
if success {
game.buffSystem.useConsumableItem(type: .coffee)
}
@@ -289,7 +289,7 @@ struct DodgeGameTestView: View {
private func useEnergyDrink() {
Task {
- let success = await game.user.inventory.drink(.energyDrink)
+ let success = game.user.inventory.drink(.energyDrink)
if success {
game.buffSystem.useConsumableItem(type: .energyDrink)
}
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Devlopment/Presentation/LanguageGameTestView.swift b/SoloDeveloperTraining/SoloDeveloperTraining/Devlopment/Presentation/LanguageGameTestView.swift
index ad75e5ae..395d7e46 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/Devlopment/Presentation/LanguageGameTestView.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/Devlopment/Presentation/LanguageGameTestView.swift
@@ -95,11 +95,9 @@ struct LanguageGameTestView: View {
private extension LanguageGameTestView {
func useConsumableItem(_ type: ConsumableType) {
- Task {
- let isSuccess = await user.inventory.drink(.coffee)
- if isSuccess {
- self.updateConsumableItems()
- }
+ let isSuccess = user.inventory.drink(.coffee)
+ if isSuccess {
+ self.updateConsumableItems()
}
}
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Devlopment/Presentation/MissionTestView.swift b/SoloDeveloperTraining/SoloDeveloperTraining/Devlopment/Presentation/MissionTestView.swift
index 6ce5ea80..82124f8b 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/Devlopment/Presentation/MissionTestView.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/Devlopment/Presentation/MissionTestView.swift
@@ -694,7 +694,7 @@ struct MissionTestView: View {
private func addPlayTime(hours: Int) {
let seconds = TimeInterval(hours * 3600)
- record.record(.playTime(tapGame: seconds))
+ record.record(.playTime)
missionSystem.updateCompletedMissions(record: record)
}
}
@@ -819,3 +819,4 @@ struct MissionCardTest: View {
#Preview {
MissionTestView()
}
+
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Extensions/Int+.swift b/SoloDeveloperTraining/SoloDeveloperTraining/Extensions/Int+.swift
index 05f874e1..b16e9401 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/Extensions/Int+.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/Extensions/Int+.swift
@@ -21,9 +21,12 @@ extension Int {
case 1_000_000..<1_000_000_000:
let value = Double(absValue) / 1_000_000.0
return "\(sign)\(formatDecimal(value))M"
- default:
+ case 1_000_000_000..<1_000_000_000_000:
let value = Double(absValue) / 1_000_000_000.0
return "\(sign)\(formatDecimal(value))B"
+ default:
+ let value = Double(absValue) / 1_000_000_000_000.0
+ return "\(sign)\(formatDecimal(value))T"
}
}
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Extensions/LongPressRepeatModifier.swift b/SoloDeveloperTraining/SoloDeveloperTraining/Extensions/LongPressRepeatModifier.swift
new file mode 100644
index 00000000..b154e844
--- /dev/null
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/Extensions/LongPressRepeatModifier.swift
@@ -0,0 +1,64 @@
+//
+// LongPressRepeatModifier.swift
+// SoloDeveloperTraining
+//
+
+import SwiftUI
+
+private enum Constant {
+ static let minimumDuration: Double = 1
+ static let repeatInterval: TimeInterval = 0.2
+}
+
+struct LongPressRepeatModifier: ViewModifier {
+ @Binding var isLongPressing: Bool
+ @State private var repeatTimer: Timer?
+
+ let isDisabled: Bool
+ let onLongPressRepeat: (() -> Bool)?
+
+ func body(content: Content) -> some View {
+ content
+ .onLongPressGesture(
+ minimumDuration: Constant.minimumDuration,
+ pressing: handlePressingChange,
+ perform: startRepeating
+ )
+ .onDisappear {
+ repeatTimer?.invalidate()
+ repeatTimer = nil
+ }
+ }
+}
+
+private extension LongPressRepeatModifier {
+ func handlePressingChange(_ pressing: Bool) {
+ guard !isDisabled, onLongPressRepeat != nil else { return }
+ if !pressing { stopRepeating() }
+ }
+
+ func startRepeating() {
+ guard let onLongPressRepeat, repeatTimer == nil else { return }
+
+ isLongPressing = true
+ if onLongPressRepeat() { SoundService.shared.trigger(.buttonTap) }
+
+ let timer = Timer.scheduledTimer(withTimeInterval: Constant.repeatInterval, repeats: true) { [onLongPressRepeat, isLongPressingBinding = $isLongPressing] timer in
+ if onLongPressRepeat() {
+ SoundService.shared.trigger(.buttonTap)
+ } else {
+ timer.invalidate()
+ repeatTimer = nil
+ isLongPressingBinding.wrappedValue = false
+ }
+ }
+ RunLoop.current.add(timer, forMode: .common)
+ repeatTimer = timer
+ }
+
+ func stopRepeating() {
+ repeatTimer?.invalidate()
+ repeatTimer = nil
+ isLongPressing = false
+ }
+}
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Extensions/View+.swift b/SoloDeveloperTraining/SoloDeveloperTraining/Extensions/View+.swift
index d8ac054d..7476e977 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/Extensions/View+.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/Extensions/View+.swift
@@ -33,4 +33,16 @@ extension View {
)
)
}
+
+ func longPressRepeat(
+ isLongPressing: Binding,
+ isDisabled: Bool,
+ onLongPressRepeat: (() -> Bool)?
+ ) -> some View {
+ modifier(LongPressRepeatModifier(
+ isLongPressing: isLongPressing,
+ isDisabled: isDisabled,
+ onLongPressRepeat: onLongPressRepeat
+ ))
+ }
}
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Games/LanguageGame.swift b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Games/LanguageGame.swift
index e5ccc2b3..01d1d6fa 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Games/LanguageGame.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Games/LanguageGame.swift
@@ -86,6 +86,9 @@ final class LanguageGame: Game {
func startGame() {
feverSystem.start()
+ if itemList.isEmpty {
+ itemList = makeInitialItemList()
+ }
}
func stopGame() {
@@ -107,7 +110,17 @@ final class LanguageGame: Game {
}
func didPerformAction(_ input: LanguageType) async -> Int {
+ // Task가 취소되었으면 즉시 종료
+ guard !Task.isCancelled else { return 0 }
+
+ // 게임 종료 후 버튼 탭 크래시 방지
+ guard itemList.count > leadingAndTrailingItemCount else { return 0 }
+
let isSuccess = languageButtonTapHandler(tappedItemType: input)
+
+ // 비즈니스 로직 실행 전 다시 한 번 취소 확인
+ guard !Task.isCancelled else { return 0 }
+
feverSystem
.gainFever(
isSuccess ? Policy.Fever.Language.gainPerCorrect : Policy.Fever.Language.lossPerIncorrect
@@ -119,7 +132,7 @@ final class LanguageGame: Game {
buffMultiplier: buffSystem.multiplier
)
if isSuccess {
- await user.wallet.addGold(gainGold)
+ user.wallet.addGold(gainGold)
/// 정답 횟수 기록
user.record.record(.languageCorrect)
/// 누적 재산 업데이트
@@ -128,7 +141,7 @@ final class LanguageGame: Game {
animationSystem?.playSmile()
return gainGold
}
- await user.wallet.spendGold(Int(Double(gainGold) * Policy.Game.Language.incorrectGoldLossMultiplier))
+ user.wallet.spendGold(Int(Double(gainGold) * Policy.Game.Language.incorrectGoldLossMultiplier))
/// 오답 횟수 기록
user.record.record(.languageIncorrect)
return Int(Double(gainGold) * Policy.Game.Language.incorrectGoldLossMultiplier) * -1
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Items/Equipment.swift b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Items/Equipment.swift
index 219c0903..e49cfc2a 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Items/Equipment.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Items/Equipment.swift
@@ -15,7 +15,7 @@ final class Equipment: Item {
}
var description: String {
guard canUpgrade else {
- return "최종 단계"
+ return "초당 골드 획득량 \(goldPerSecond.formatted)"
}
let nextTier = EquipmentTier(rawValue: tier.rawValue + 1) ?? .nationalTreasure
let nextEquipment = Equipment(type: type, tier: nextTier)
@@ -215,25 +215,25 @@ enum EquipmentTier: Int {
}
}
- /// 업그레이드 비용
+ /// 업그레이드 비용 (골드 + 다이아몬드)
var cost: Cost {
switch self {
case .broken:
- return Cost(gold: Policy.Equipment.brokenUpgradeCost)
+ return Cost(gold: Policy.Equipment.brokenUpgradeCost, diamond: Policy.Equipment.brokenUpgradeDiamond)
case .cheap:
- return Cost(gold: Policy.Equipment.cheapUpgradeCost)
+ return Cost(gold: Policy.Equipment.cheapUpgradeCost, diamond: Policy.Equipment.cheapUpgradeDiamond)
case .vintage:
- return Cost(gold: Policy.Equipment.vintageUpgradeCost)
+ return Cost(gold: Policy.Equipment.vintageUpgradeCost, diamond: Policy.Equipment.vintageUpgradeDiamond)
case .decent:
- return Cost(gold: Policy.Equipment.decentUpgradeCost)
+ return Cost(gold: Policy.Equipment.decentUpgradeCost, diamond: Policy.Equipment.decentUpgradeDiamond)
case .premium:
- return Cost(gold: Policy.Equipment.premiumUpgradeCost)
+ return Cost(gold: Policy.Equipment.premiumUpgradeCost, diamond: Policy.Equipment.premiumUpgradeDiamond)
case .diamond:
- return Cost(gold: Policy.Equipment.diamondUpgradeCost)
+ return Cost(gold: Policy.Equipment.diamondUpgradeCost, diamond: Policy.Equipment.diamondUpgradeDiamond)
case .limited:
- return Cost(gold: Policy.Equipment.limitedUpgradeCost)
+ return Cost(gold: Policy.Equipment.limitedUpgradeCost, diamond: Policy.Equipment.limitedUpgradeDiamond)
case .nationalTreasure:
- return Cost(gold: Policy.Equipment.nationalTreasureUpgradeCost)
+ return Cost(gold: Policy.Equipment.nationalTreasureUpgradeCost, diamond: Policy.Equipment.nationalTreasureUpgradeDiamond)
}
}
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Items/ItemState.swift b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Items/ItemState.swift
index fd28a64a..7f9f807a 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Items/ItemState.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Items/ItemState.swift
@@ -11,23 +11,38 @@ enum ItemState {
case available // 구매 가능
case locked // 잠김
case insufficient // 비용 부족
+ case reachedMax // 최고 레벨
init(item: DisplayItem) {
+ // 장비 최고 레벨 체크
+ if let equipment = item.item as? Equipment, !equipment.canUpgrade {
+ self = .reachedMax
+ return
+ }
+
+ // 주거 아이템이 이미 장착되어 있으면 잠김
if item.isEquipped && item.category == .housing {
self = .locked
- } else if item.isPurchasable {
+ return
+ }
+
+ // 구매 가능 여부
+ if item.isPurchasable {
self = .available
} else {
self = .insufficient
}
}
- init(canUnlock: Bool, canAfford: Bool) {
- if !canUnlock {
+ init(canUpgrade: Bool, canUnlock: Bool, canAfford: Bool) {
+ switch (canUpgrade, canUnlock, canAfford) {
+ case (false, _, _):
+ self = .reachedMax
+ case (true, false, _):
self = .locked
- } else if !canAfford {
+ case (true, true, false):
self = .insufficient
- } else {
+ case (true, true, true):
self = .available
}
}
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Policy.swift b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Policy.swift
index cab9f948..b6e671f7 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Policy.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Policy.swift
@@ -9,25 +9,25 @@ import Foundation
enum Policy {
// MARK: - 커리어 시스템 (기준점)
- /// 단계별 필요 누적 재산
+ /// 단계별 필요 누적 재산 (밸런스에 맞춰 약 20× 상향)
enum Career {
static let unemployed = 0
- static let laptopOwner = 5_000
- static let aspiringDeveloper = 50_000
- static let juniorDeveloper = 1_000_000
- static let normalDeveloper = 100_000_000
- static let nightOwlDeveloper = 1_000_000_000
- static let skilledDeveloper = 10_000_000_000
- static let famousDeveloper = 100_000_000_000
- static let allRounderDeveloper = 50_000_000_000_000
- static let worldClassDeveloper = 1_000_000_000_000_000 // 1000조: 만렙
-
- /// 게임별 해금 조건
+ static let laptopOwner = 100_000
+ static let aspiringDeveloper = 1_000_000
+ static let juniorDeveloper = 20_000_000
+ static let normalDeveloper = 2_000_000_000
+ static let nightOwlDeveloper = 20_000_000_000
+ static let skilledDeveloper = 200_000_000_000
+ static let famousDeveloper = 2_000_000_000_000
+ static let allRounderDeveloper = 100_000_000_000_000 // 100조
+ static let worldClassDeveloper = 2_000_000_000_000_000 // 2000조: 만렙
+
+ /// 게임별 해금 조건 (2단계씩: 탭 0 → 언어 2 → 버그 4 → 데이터 6)
enum GameUnlock {
- static let tap = unemployed
- static let language = laptopOwner
- static let dodge = aspiringDeveloper
- static let stack = juniorDeveloper
+ static let tap = unemployed // 0단계
+ static let language = aspiringDeveloper // 2단계
+ static let dodge = normalDeveloper // 4단계
+ static let stack = skilledDeveloper // 6단계
}
}
@@ -53,10 +53,10 @@ enum Policy {
static let stage3: Double = 5.0 // 2.0 -> 5.0
}
- /// 코드 짜기 (TapGame)
+ /// 코드 짜기 (TapGame) — 피버 상승량 대폭 하향
enum Tap {
static let decreasePercent: Double = 1.5
- static let gainPerTap: Double = 10.0
+ static let gainPerTap: Double = 5.0 // 2.0 -> 5.0
}
/// 언어 맞추기 (LanguageGame)
@@ -166,19 +166,20 @@ enum Policy {
}
/// 언어 맞추기 (LanguageGame)
+ /// * 분당 골드 = 탭의 3배 (피버 2.5 기준): 40정답/분 → Lv1,0,0 합 180
enum Language {
- // 기본 골드 단위 (상향: 최소 1)
- static let baseGold: Int = 1
+ // 기본 골드 단위
+ static let baseGold: Int = 45
// 티어별 골드 획득 증가량
- static let beginnerGoldMultiplier: Int = 1
- static let intermediateGoldMultiplier: Int = 10
- static let advancedGoldMultiplier: Int = 100
-
- // 업그레이드 비용 증가량
- static let beginnerGoldCostMultiplier: Int = 10
- static let intermediateGoldCostMultiplier: Int = 150
- static let advancedGoldCostMultiplier: Int = 2500
+ static let beginnerGoldMultiplier: Int = 45
+ static let intermediateGoldMultiplier: Int = 450
+ static let advancedGoldMultiplier: Int = 4500
+
+ // 업그레이드 비용 증가량 (Tap 대비 스킬 합 비율 45배)
+ static let beginnerGoldCostMultiplier: Int = 450
+ static let intermediateGoldCostMultiplier: Int = 6750
+ static let advancedGoldCostMultiplier: Int = 112_500
static let diamondCostDivider: Int = 100
static let diamondCostMultiplier: Int = 10
@@ -188,19 +189,20 @@ enum Policy {
}
/// 버그 피하기 (DodgeGame)
+ /// * 아무튼 개발자(20억) 해금 시 언어(초250·중300·고100) 분당 ~6천만 수준에 맞춤: Lv1,0,0 합 348,000
enum Dodge {
- // 기본 골드 단위 (상향: 최소 1)
- static let baseGold: Int = 1
+ // 기본 골드 단위
+ static let baseGold: Int = 87_000
// 티어별 골드 획득 증가량
- static let beginnerGoldMultiplier: Int = 1
- static let intermediateGoldMultiplier: Int = 10
- static let advancedGoldMultiplier: Int = 100
-
- // 업그레이드 비용 증가량
- static let beginnerGoldCostMultiplier: Int = 10
- static let intermediateGoldCostMultiplier: Int = 150
- static let advancedGoldCostMultiplier: Int = 2500
+ static let beginnerGoldMultiplier: Int = 87_000
+ static let intermediateGoldMultiplier: Int = 870_000
+ static let advancedGoldMultiplier: Int = 8_700_000
+
+ // 업그레이드 비용 증가량 (스킬 합 비율에 맞춤)
+ static let beginnerGoldCostMultiplier: Int = 870_000
+ static let intermediateGoldCostMultiplier: Int = 13_050_000
+ static let advancedGoldCostMultiplier: Int = 217_500_000
static let diamondCostDivider: Int = 100
static let diamondCostMultiplier: Int = 10
@@ -210,19 +212,20 @@ enum Policy {
}
/// 데이터 쌓기 (StackGame)
+ /// * 유능한 개발자(2000억) 해금 시 버그 피하기 대비 보상 상향: Lv1,0,0 합 9,000,000 (순 8회/분 → 분당 ~1.8억)
enum Stack {
- // 기본 골드 단위 (상향: 최소 1)
- static let baseGold: Int = 1
+ // 기본 골드 단위
+ static let baseGold: Int = 2_250_000
// 티어별 골드 획득 증가량
- static let beginnerGoldMultiplier: Int = 1
- static let intermediateGoldMultiplier: Int = 10
- static let advancedGoldMultiplier: Int = 100
-
- // 업그레이드 비용 증가량
- static let beginnerGoldCostMultiplier: Int = 10
- static let intermediateGoldCostMultiplier: Int = 150
- static let advancedGoldCostMultiplier: Int = 2500
+ static let beginnerGoldMultiplier: Int = 2_250_000
+ static let intermediateGoldMultiplier: Int = 22_500_000
+ static let advancedGoldMultiplier: Int = 225_000_000
+
+ // 업그레이드 비용 증가량 (스킬 합 비율에 맞춤)
+ static let beginnerGoldCostMultiplier: Int = 22_500_000
+ static let intermediateGoldCostMultiplier: Int = 3_375_000_000
+ static let advancedGoldCostMultiplier: Int = 56_250_000_000
static let diamondCostDivider: Int = 100
static let diamondCostMultiplier: Int = 10
@@ -250,16 +253,27 @@ enum Policy {
}
// MARK: - 장비 아이템
+ // *밸런스: 업그레이드 비용 20×, 초당 골드 5× (부동산과 동일 비율)
enum Equipment {
- // 업그레이드 비용 (진입 장벽 완화)
- static let brokenUpgradeCost: Int = 5_000
- static let cheapUpgradeCost: Int = 100_000
- static let vintageUpgradeCost: Int = 2_000_000
- static let decentUpgradeCost: Int = 50_000_000
- static let premiumUpgradeCost: Int = 1_000_000_000
- static let diamondUpgradeCost: Int = 10_000_000_000
- static let limitedUpgradeCost: Int = 50_000_000_000
- static let nationalTreasureUpgradeCost: Int = 200_000_000_000
+ // 업그레이드 비용 (골드)
+ static let brokenUpgradeCost: Int = 100_000
+ static let cheapUpgradeCost: Int = 2_000_000
+ static let vintageUpgradeCost: Int = 40_000_000
+ static let decentUpgradeCost: Int = 1_000_000_000
+ static let premiumUpgradeCost: Int = 20_000_000_000
+ static let diamondUpgradeCost: Int = 200_000_000_000
+ static let limitedUpgradeCost: Int = 1_000_000_000_000
+ static let nationalTreasureUpgradeCost: Int = 4_000_000_000_000
+
+ // 업그레이드 비용 (다이아몬드) — 강화 시 골드와 함께 소모
+ static let brokenUpgradeDiamond: Int = 5
+ static let cheapUpgradeDiamond: Int = 10
+ static let vintageUpgradeDiamond: Int = 20
+ static let decentUpgradeDiamond: Int = 35
+ static let premiumUpgradeDiamond: Int = 50
+ static let diamondUpgradeDiamond: Int = 80
+ static let limitedUpgradeDiamond: Int = 120
+ static let nationalTreasureUpgradeDiamond: Int = 0
// 업그레이드 성공 확률 (모든 장비 공통)
static let brokenSuccessRate: Double = 1.0
@@ -271,76 +285,75 @@ enum Policy {
static let limitedSuccessRate: Double = 0.1
static let nationalTreasureSuccessRate: Double = 0.05
- /// 초당 획득 골드량
- /// 키보드
+ /// 초당 획득 골드량 (키보드·마우스·모니터·의자 동일)
enum Keyboard {
- static let brokenGoldPerSecond: Int = 10
- static let cheapGoldPerSecond: Int = 250
- static let vintageGoldPerSecond: Int = 6_000
- static let decentGoldPerSecond: Int = 150_000
- static let premiumGoldPerSecond: Int = 3_500_000
- static let diamondGoldPerSecond: Int = 40_000_000
- static let limitedGoldPerSecond: Int = 250_000_000
- static let nationalTreasureGoldPerSecond: Int = 1_200_000_000
+ static let brokenGoldPerSecond: Int = 0
+ static let cheapGoldPerSecond: Int = 1_250
+ static let vintageGoldPerSecond: Int = 30_000
+ static let decentGoldPerSecond: Int = 750_000
+ static let premiumGoldPerSecond: Int = 17_500_000
+ static let diamondGoldPerSecond: Int = 200_000_000
+ static let limitedGoldPerSecond: Int = 1_250_000_000
+ static let nationalTreasureGoldPerSecond: Int = 6_000_000_000
}
/// 마우스
enum Mouse {
- static let brokenGoldPerSecond: Int = 10
- static let cheapGoldPerSecond: Int = 250
- static let vintageGoldPerSecond: Int = 6_000
- static let decentGoldPerSecond: Int = 150_000
- static let premiumGoldPerSecond: Int = 3_500_000
- static let diamondGoldPerSecond: Int = 40_000_000
- static let limitedGoldPerSecond: Int = 250_000_000
- static let nationalTreasureGoldPerSecond: Int = 1_200_000_000
+ static let brokenGoldPerSecond: Int = 0
+ static let cheapGoldPerSecond: Int = 1_250
+ static let vintageGoldPerSecond: Int = 30_000
+ static let decentGoldPerSecond: Int = 750_000
+ static let premiumGoldPerSecond: Int = 17_500_000
+ static let diamondGoldPerSecond: Int = 200_000_000
+ static let limitedGoldPerSecond: Int = 1_250_000_000
+ static let nationalTreasureGoldPerSecond: Int = 6_000_000_000
}
/// 모니터
enum Monitor {
- static let brokenGoldPerSecond: Int = 10
- static let cheapGoldPerSecond: Int = 250
- static let vintageGoldPerSecond: Int = 6_000
- static let decentGoldPerSecond: Int = 150_000
- static let premiumGoldPerSecond: Int = 3_500_000
- static let diamondGoldPerSecond: Int = 40_000_000
- static let limitedGoldPerSecond: Int = 250_000_000
- static let nationalTreasureGoldPerSecond: Int = 1_200_000_000
+ static let brokenGoldPerSecond: Int = 0
+ static let cheapGoldPerSecond: Int = 1_250
+ static let vintageGoldPerSecond: Int = 30_000
+ static let decentGoldPerSecond: Int = 750_000
+ static let premiumGoldPerSecond: Int = 17_500_000
+ static let diamondGoldPerSecond: Int = 200_000_000
+ static let limitedGoldPerSecond: Int = 1_250_000_000
+ static let nationalTreasureGoldPerSecond: Int = 6_000_000_000
}
/// 의자
enum Chair {
- static let brokenGoldPerSecond: Int = 10
- static let cheapGoldPerSecond: Int = 250
- static let vintageGoldPerSecond: Int = 6_000
- static let decentGoldPerSecond: Int = 150_000
- static let premiumGoldPerSecond: Int = 3_500_000
- static let diamondGoldPerSecond: Int = 40_000_000
- static let limitedGoldPerSecond: Int = 250_000_000
- static let nationalTreasureGoldPerSecond: Int = 1_200_000_000
+ static let brokenGoldPerSecond: Int = 0
+ static let cheapGoldPerSecond: Int = 1_250
+ static let vintageGoldPerSecond: Int = 30_000
+ static let decentGoldPerSecond: Int = 750_000
+ static let premiumGoldPerSecond: Int = 17_500_000
+ static let diamondGoldPerSecond: Int = 200_000_000
+ static let limitedGoldPerSecond: Int = 1_250_000_000
+ static let nationalTreasureGoldPerSecond: Int = 6_000_000_000
}
}
// MARK: - 부동산 아이템 (로망 실현 및 자동 사냥 기지)
- // *전략: 부동산 가격은 비싸지만, 이사 가면 기본 소득(Basic Income)이 확 늘어나도록 설정
+ // *밸런스: 가격·초당 골드를 분당 3배수 경제에 맞춤 (가격 20×, 초당 골드 5× → 회수 시간 약 4배)
enum Housing {
// 구입 비용
static let streetPurchaseCost: Int = 0
- static let semiBasementPurchaseCost: Int = 500_000
- static let rooftopPurchaseCost: Int = 10_000_000
- static let villaPurchaseCost: Int = 500_000_000
- static let apartmentPurchaseCost: Int = 5_000_000_000
- static let housePurchaseCost: Int = 50_000_000_000
- static let pentHousePurchaseCost: Int = 200_000_000_000
-
- // 초당 골드 획득
+ static let semiBasementPurchaseCost: Int = 10_000_000
+ static let rooftopPurchaseCost: Int = 200_000_000
+ static let villaPurchaseCost: Int = 10_000_000_000
+ static let apartmentPurchaseCost: Int = 100_000_000_000
+ static let housePurchaseCost: Int = 1_000_000_000_000
+ static let pentHousePurchaseCost: Int = 4_000_000_000_000
+
+ // 초당 골드 획득 (분당 = ×60)
static let streetGoldPerSecond: Int = 0
- static let semiBasementGoldPerSecond: Int = 500
- static let rooftopGoldPerSecond: Int = 10_000
- static let villaGoldPerSecond: Int = 500_000
- static let apartmentGoldPerSecond: Int = 5_000_000
- static let houseGoldPerSecond: Int = 50_000_000
- static let pentHouseGoldPerSecond: Int = 200_000_000
+ static let semiBasementGoldPerSecond: Int = 2_500
+ static let rooftopGoldPerSecond: Int = 50_000
+ static let villaGoldPerSecond: Int = 2_500_000
+ static let apartmentGoldPerSecond: Int = 25_000_000
+ static let houseGoldPerSecond: Int = 250_000_000
+ static let pentHouseGoldPerSecond: Int = 1_000_000_000
}
// MARK: - 기타 시스템
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/AutoGainSystem.swift b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/AutoGainSystem.swift
index b40e7adc..f9cad2fb 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/AutoGainSystem.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/AutoGainSystem.swift
@@ -44,5 +44,8 @@ final class AutoGainSystem {
user.wallet.addGold(goldPerSecond)
/// 누적 재산 업데이트
user.record.record(.earnMoney(goldPerSecond))
+
+ // 플레이 타임 기록
+ user.record.record(.playTime)
}
}
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/CareerSystem.swift b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/CareerSystem.swift
index 17195e60..0841c5d8 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/CareerSystem.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/CareerSystem.swift
@@ -17,7 +17,7 @@ final class CareerSystem {
init(user: User) async {
self.user = user
- self.currentCareer = await user.career
+ self.currentCareer = user.career
await updateProgress()
}
@@ -41,7 +41,7 @@ final class CareerSystem {
let newCareer = await calculateCareer()
if currentCareer != newCareer {
currentCareer = newCareer
- await user.updateCareer(to: newCareer)
+ user.updateCareer(to: newCareer)
onCareerChanged?(newCareer)
if newCareer == .juniorDeveloper {
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/MissionConstants.swift b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/MissionConstants.swift
index cdb08ca0..7b8d8efd 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/MissionConstants.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/MissionConstants.swift
@@ -17,9 +17,9 @@ enum MissionConstants {
static let description1 = "코드짜기\n탭 1,000회 달성"
static let description2 = "코드짜기\n탭 10,000회 달성"
static let description3 = "코드짜기\n탭 100,000회 달성"
- static let reward1 = Cost(gold: 1_000)
+ static let reward1 = Cost(gold: 5_000)
static let reward2 = Cost(diamond: 10)
- static let reward3 = Cost(gold: 10_000, diamond: 20)
+ static let reward3 = Cost(gold: 50_000, diamond: 20)
}
// MARK: - 언어맞추기 (맞춘 횟수)
@@ -31,8 +31,8 @@ enum MissionConstants {
static let description2 = "언어맞추기\n정답 5,000회 달성"
static let description3 = "언어맞추기\n정답 50,000회 달성"
static let reward1 = Cost(diamond: 5)
- static let reward2 = Cost(gold: 5_000)
- static let reward3 = Cost(gold: 50_000, diamond: 30)
+ static let reward2 = Cost(gold: 225_000)
+ static let reward3 = Cost(gold: 2_250_000, diamond: 30)
}
// MARK: - 버그피하기 (골드 획득)
@@ -43,9 +43,9 @@ enum MissionConstants {
static let description1 = "버그피하기\n골드 300회 획득"
static let description2 = "버그피하기\n골드 3,000회 획득"
static let description3 = "버그피하기\n골드 30,000회 획득"
- static let reward1 = Cost(gold: 2_000)
+ static let reward1 = Cost(gold: 174_000_000)
static let reward2 = Cost(diamond: 15)
- static let reward3 = Cost(gold: 20_000, diamond: 25)
+ static let reward3 = Cost(gold: 1_740_000_000, diamond: 25)
}
// MARK: - 데이터쌓기
@@ -57,8 +57,8 @@ enum MissionConstants {
static let description2 = "데이터 쌓기\n데이터 1,000회 쌓기"
static let description3 = "데이터 쌓기\n데이터 10,000회 쌓기"
static let reward1 = Cost(diamond: 8)
- static let reward2 = Cost(gold: 8_000)
- static let reward3 = Cost(gold: 80_000, diamond: 40)
+ static let reward2 = Cost(gold: 18_000_000_000)
+ static let reward3 = Cost(gold: 180_000_000_000, diamond: 40)
}
// MARK: - 플레이타임
@@ -69,9 +69,9 @@ enum MissionConstants {
static let description1 = "총 플레이 시간 1시간"
static let description2 = "총 플레이 시간 10시간"
static let description3 = "총 플레이 시간 100시간"
- static let reward1 = Cost(gold: 500)
- static let reward2 = Cost(diamond: 12)
- static let reward3 = Cost(gold: 100_000, diamond: 50)
+ static let reward1 = Cost(diamond: 50)
+ static let reward2 = Cost(diamond: 120)
+ static let reward3 = Cost(diamond: 500)
}
// MARK: - 커피
@@ -82,9 +82,9 @@ enum MissionConstants {
static let description1 = "커피 10회 사용"
static let description2 = "커피 100회 사용"
static let description3 = "커피 1,000회 사용"
- static let reward1 = Cost(gold: 1_500)
+ static let reward1 = Cost(gold: 5_000)
static let reward2 = Cost(diamond: 10)
- static let reward3 = Cost(gold: 15_000, diamond: 18)
+ static let reward3 = Cost(gold: 50_000, diamond: 18)
}
// MARK: - 박하스
@@ -96,8 +96,8 @@ enum MissionConstants {
static let description2 = "박하스 100회 사용"
static let description3 = "박하스 1,000회 사용"
static let reward1 = Cost(diamond: 7)
- static let reward2 = Cost(gold: 7_000)
- static let reward3 = Cost(gold: 70_000, diamond: 35)
+ static let reward2 = Cost(gold: 225_000)
+ static let reward3 = Cost(gold: 2_250_000, diamond: 35)
}
// MARK: - 언어맞추기 (연속 성공)
@@ -108,9 +108,9 @@ enum MissionConstants {
static let description1 = "언어맞추기\n연속 정답 10회"
static let description2 = "언어맞추기\n연속 정답 100회"
static let description3 = "언어맞추기\n연속 정답 1,000회"
- static let reward1 = Cost(gold: 3_000)
+ static let reward1 = Cost(gold: 135_000)
static let reward2 = Cost(diamond: 20)
- static let reward3 = Cost(gold: 30_000, diamond: 40)
+ static let reward3 = Cost(gold: 1_350_000, diamond: 40)
}
// MARK: - 버그피하기 (연속 성공)
@@ -122,8 +122,8 @@ enum MissionConstants {
static let description2 = "버그피하기\n연속 성공 100회"
static let description3 = "버그피하기\n연속 성공 1,000회"
static let reward1 = Cost(diamond: 12)
- static let reward2 = Cost(gold: 12_000)
- static let reward3 = Cost(gold: 120_000, diamond: 60)
+ static let reward2 = Cost(gold: 1_044_000_000)
+ static let reward3 = Cost(gold: 10_440_000_000, diamond: 60)
}
// MARK: - 데이터 쌓기 (연속 성공)
@@ -134,9 +134,9 @@ enum MissionConstants {
static let description1 = "데이터 쌓기\n연속 성공 10회"
static let description2 = "데이터 쌓기\n연속 성공 100회"
static let description3 = "데이터 쌓기\n연속 성공 1,000회"
- static let reward1 = Cost(gold: 4_000)
+ static let reward1 = Cost(gold: 9_000_000_000)
static let reward2 = Cost(diamond: 18)
- static let reward3 = Cost(gold: 40_000, diamond: 45)
+ static let reward3 = Cost(gold: 90_000_000_000, diamond: 45)
}
// MARK: - 커리어
@@ -144,7 +144,7 @@ enum MissionConstants {
static let id = 31
static let title = "나는야 개발자"
static let description = "하찮은 개발자 달성"
- static let reward = Cost(gold: 50_000, diamond: 100)
+ static let reward = Cost(gold: 2_500_000, diamond: 100)
}
// MARK: - 튜토리얼
@@ -152,6 +152,6 @@ enum MissionConstants {
static let id = 32
static let title = "주니어 개발자"
static let description = "튜토리얼 완료"
- static let reward = Cost(gold: 10_000, diamond: 10)
+ static let reward = Cost(gold: 50_000, diamond: 10)
}
}
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/SkillSystem.swift b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/SkillSystem.swift
index af508f55..b813ab0a 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/SkillSystem.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/Systems/SkillSystem.swift
@@ -72,11 +72,12 @@ private extension SkillSystem {
}
func getItemState(for skill: Skill) -> ItemState {
+ let canUpgrade = skill.level < skill.key.tier.levelRange.maxValue
let canUnlock = canUnlock(skill: skill)
let cost = skill.upgradeCost
let canAfford = cost.gold <= user.wallet.gold && cost.diamond <= user.wallet.diamond
- return ItemState(canUnlock: canUnlock, canAfford: canAfford)
+ return ItemState(canUpgrade: canUpgrade, canUnlock: canUnlock, canAfford: canAfford)
}
func canUnlock(skill: Skill) -> Bool {
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Inventory.swift b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Inventory.swift
index 3fe2a3d0..120b4793 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Inventory.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Inventory.swift
@@ -8,6 +8,7 @@
import Foundation
import Observation
+@MainActor
@Observable
final class Inventory {
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Mission.swift b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Mission.swift
index d5a43785..eca60e5c 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Mission.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Mission.swift
@@ -34,6 +34,7 @@ enum MissionLevel {
}
}
+@MainActor
@Observable
final class Mission {
enum State: Int {
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Record.swift b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Record.swift
index a7245940..40853913 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Record.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Record.swift
@@ -8,6 +8,7 @@
import Foundation
import Observation
+@MainActor
@Observable
final class Record {
/// 미션 시스템을 소유해서 기록을 반영합니다.
@@ -66,14 +67,6 @@ final class Record {
// MARK: - Play Time Records
/// 총 플레이 시간
var totalPlayTime: TimeInterval = 0
- /// 탭 게임 플레이 시간
- var tapGamePlayTime: TimeInterval = 0
- /// 언어 맞추기 게임 플레이 시간
- var languageGamePlayTime: TimeInterval = 0
- /// 버그 피하기 게임 플레이 시간
- var dodgeGamePlayTime: TimeInterval = 0
- /// 데이터 쌓기 게임 플레이 시간
- var stackGamePlayTime: TimeInterval = 0
// MARK: - Tutorial Records
/// 튜토리얼 클리어 여부
@@ -108,12 +101,7 @@ extension Record {
case energyDrinkUse
// Play Time
- case playTime(
- tapGame: TimeInterval = 0,
- languageGame: TimeInterval = 0,
- dodgeGame: TimeInterval = 0,
- stackGame: TimeInterval = 0
- )
+ case playTime
// Financial
case earnMoney(Int)
@@ -164,23 +152,8 @@ extension Record {
case .energyDrinkUse:
energyDrinkUseCount += 1
- case .playTime(let tap, let language, let dodge, let stack):
- if tap > 0 {
- tapGamePlayTime += tap
- totalPlayTime += tap
- }
- if language > 0 {
- languageGamePlayTime += language
- totalPlayTime += language
- }
- if dodge > 0 {
- dodgeGamePlayTime += dodge
- totalPlayTime += dodge
- }
- if stack > 0 {
- stackGamePlayTime += stack
- totalPlayTime += stack
- }
+ case .playTime:
+ totalPlayTime += 1
case .earnMoney(let amount):
totalEarnedMoney += amount
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/User.swift b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/User.swift
index 713086df..24688128 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/User.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/User.swift
@@ -7,7 +7,8 @@
import Foundation
-actor User {
+@MainActor
+final class User {
/// 유저 식별용 ID
let id: UUID
/// 유저 닉네임
@@ -46,8 +47,7 @@ actor User {
career = newCareer
}
- @MainActor
- init(nickname: String) {
+ convenience init(nickname: String) {
self.init(
nickname: nickname,
wallet: .init(),
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Wallet.swift b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Wallet.swift
index 9133e0b5..036cffa2 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Wallet.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/GameCore/Models/User/Wallet.swift
@@ -9,6 +9,7 @@ import Foundation
import Observation
/// 게임 내 재화 관리 클래스
+@MainActor
@Observable
final class Wallet {
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Production/Data/DTOs/RecordDTO.swift b/SoloDeveloperTraining/SoloDeveloperTraining/Production/Data/DTOs/RecordDTO.swift
index 5af39942..3f19970c 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/Production/Data/DTOs/RecordDTO.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/Production/Data/DTOs/RecordDTO.swift
@@ -39,10 +39,6 @@ struct RecordDTO: Codable {
// Play Time Records
let totalPlayTime: TimeInterval
- let tapGamePlayTime: TimeInterval
- let languageGamePlayTime: TimeInterval
- let dodgeGamePlayTime: TimeInterval
- let stackGamePlayTime: TimeInterval
// Tutorial Records
let tutorialCompleted: Bool
@@ -72,10 +68,6 @@ struct RecordDTO: Codable {
self.coffeeUseCount = record.coffeeUseCount
self.energyDrinkUseCount = record.energyDrinkUseCount
self.totalPlayTime = record.totalPlayTime
- self.tapGamePlayTime = record.tapGamePlayTime
- self.languageGamePlayTime = record.languageGamePlayTime
- self.dodgeGamePlayTime = record.dodgeGamePlayTime
- self.stackGamePlayTime = record.stackGamePlayTime
self.tutorialCompleted = record.tutorialCompleted
self.hasAchievedJuniorDeveloper = record.hasAchievedJuniorDeveloper
self.missionStates = record.missionSystem.missions.map { MissionStateDTO(from: $0) }
@@ -115,10 +107,6 @@ struct RecordDTO: Codable {
// Play Time Records
record.totalPlayTime = totalPlayTime
- record.tapGamePlayTime = tapGamePlayTime
- record.languageGamePlayTime = languageGamePlayTime
- record.dodgeGamePlayTime = dodgeGamePlayTime
- record.stackGamePlayTime = stackGamePlayTime
// Tutorial Records
record.tutorialCompleted = tutorialCompleted
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Production/Data/Repository/UserRepository.swift b/SoloDeveloperTraining/SoloDeveloperTraining/Production/Data/Repository/UserRepository.swift
index 9e44f5d7..766671be 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/Production/Data/Repository/UserRepository.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/Production/Data/Repository/UserRepository.swift
@@ -24,7 +24,7 @@ final class FileManagerUserRepository: UserRepository {
func save(_ user: User) async throws {
let id = user.id
let nickname = user.nickname
- let career = await user.career
+ let career = user.career
let wallet = user.wallet
let inventory = user.inventory
let record = user.record
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/LanguageGameView.swift b/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/LanguageGameView.swift
index 454e0681..ff4ba899 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/LanguageGameView.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/LanguageGameView.swift
@@ -52,6 +52,9 @@ struct LanguageGameView: View {
/// 획득한 골드를 표시하기 위한 효과 라벨 배열
@State private var effectValues: [(id: UUID, value: Int)] = []
+ /// 현재 진행 중인 언어 버튼 탭 Task
+ @State private var currentActionTask: Task?
+
init(
user: User,
isGameStarted: Binding,
@@ -158,14 +161,25 @@ private extension LanguageGameView {
private extension LanguageGameView {
/// 닫기 버튼 클릭 처리
func handleCloseButton() {
+ // 진행 중인 액션 Task 취소
+ currentActionTask?.cancel()
+ currentActionTask = nil
+
game.stopGame()
isGameStarted = false
}
/// 언어 버튼 클릭 처리
func handleLanguageButtonTap(_ type: LanguageType) {
- Task {
+ // 이전 액션이 진행 중이면 취소
+ currentActionTask?.cancel()
+
+ currentActionTask = Task {
let gainedGold = await game.didPerformAction(type)
+
+ // Task가 취소되었으면 UI 업데이트 생략
+ guard !Task.isCancelled else { return }
+
SoundService.shared.trigger(gainedGold > 0 ? .languageCorrect : .languageWrong)
if gainedGold <= 0 {
HapticService.shared.trigger(.error)
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/ShopPurchaseHelper.swift b/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/ShopPurchaseHelper.swift
index c285f0f3..0c06d48f 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/ShopPurchaseHelper.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/ShopPurchaseHelper.swift
@@ -29,6 +29,8 @@ enum ShopPurchaseHelper {
confirmTitle: String,
onConfirm: @escaping () -> Void
) {
+ var didPurchasingButtonTapped = false
+
popupContent.wrappedValue = PopupConfiguration(
title: title,
maxHeight: nil
@@ -44,8 +46,10 @@ enum ShopPurchaseHelper {
popupContent.wrappedValue = nil
}
MediumButton(title: confirmTitle, isFilled: true) {
- popupContent.wrappedValue = nil
+ guard !didPurchasingButtonTapped else { return }
+ didPurchasingButtonTapped = true
onConfirm()
+ popupContent.wrappedValue = nil
}
}
}
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/ShopView.swift b/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/ShopView.swift
index 0d816edf..f33c3261 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/ShopView.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/ShopView.swift
@@ -42,6 +42,7 @@ struct ShopView: View {
@State private var selectedCategoryIndex: Int = 0
@State private var selectedHousingTier: HousingTier?
+
@Binding var popupContent: PopupConfiguration?
init(user: User, popupContent: Binding) {
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/SkillView.swift b/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/SkillView.swift
index 3f09b5c9..fd9c2433 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/SkillView.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/SkillView.swift
@@ -41,10 +41,10 @@ struct SkillView: View {
description: "액션당 \(Int(skillState.skill.gainGold).formatted()) 골드 획득",
imageName: skillState.skill.imageName,
cost: skillState.skill.upgradeCost,
- state: skillState.itemState
- ) {
- upgrade(skill: skillState.skill)
- }
+ state: skillState.itemState,
+ action: { upgrade(skill: skillState.skill) },
+ onLongPressAction: { upgradeRepeating(skill: skillState.skill) }
+ )
}
}
}
@@ -87,6 +87,16 @@ private extension SkillView {
}
}
}
+
+ /// 롱프레스 연속 구매용. 성공 시 `true`, 실패(재화 부족 등) 시 `false` 반환해 연속 호출 중단.
+ func upgradeRepeating(skill: Skill) -> Bool {
+ do {
+ try skillSystem.upgrade(skill: skill)
+ return true
+ } catch {
+ return false
+ }
+ }
}
#Preview {
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/TapGameView.swift b/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/TapGameView.swift
index c31b2469..862deffa 100644
--- a/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/TapGameView.swift
+++ b/SoloDeveloperTraining/SoloDeveloperTraining/Production/Presentation/TapGameView.swift
@@ -13,7 +13,7 @@ private enum Constant {
static let toolBarBottom: CGFloat = 10
}
/// 탭 사운드 최소 재생 간격 (초)
- static let tapSoundThrottleInterval: TimeInterval = 15
+ static let tapSoundThrottleInterval: TimeInterval = 0.05
}
struct TapGameView: View {
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Resources/Audio/blockStack.wav b/SoloDeveloperTraining/SoloDeveloperTraining/Resources/Audio/blockStack.wav
index 5caa4cfa..31c7c2ec 100644
Binary files a/SoloDeveloperTraining/SoloDeveloperTraining/Resources/Audio/blockStack.wav and b/SoloDeveloperTraining/SoloDeveloperTraining/Resources/Audio/blockStack.wav differ
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Resources/Audio/bombStack.wav b/SoloDeveloperTraining/SoloDeveloperTraining/Resources/Audio/bombStack.wav
index 1aa233cd..4cf958c3 100644
Binary files a/SoloDeveloperTraining/SoloDeveloperTraining/Resources/Audio/bombStack.wav and b/SoloDeveloperTraining/SoloDeveloperTraining/Resources/Audio/bombStack.wav differ
diff --git a/SoloDeveloperTraining/SoloDeveloperTraining/Resources/Audio/tapGameTyping.wav b/SoloDeveloperTraining/SoloDeveloperTraining/Resources/Audio/tapGameTyping.wav
index 6c921246..d6807bd7 100644
Binary files a/SoloDeveloperTraining/SoloDeveloperTraining/Resources/Audio/tapGameTyping.wav and b/SoloDeveloperTraining/SoloDeveloperTraining/Resources/Audio/tapGameTyping.wav differ
diff --git a/SoloDeveloperTraining/SoloDeveloperTrainingTests/ConcurrencyIssueTests.swift b/SoloDeveloperTraining/SoloDeveloperTrainingTests/ConcurrencyIssueTests.swift
new file mode 100644
index 00000000..625b6c6f
--- /dev/null
+++ b/SoloDeveloperTraining/SoloDeveloperTrainingTests/ConcurrencyIssueTests.swift
@@ -0,0 +1,35 @@
+//
+// ConcurrencyIssueTests.swift
+// SoloDeveloperTrainingTests
+//
+// Created by SeoJunYoung on 2/2/26.
+//
+
+import Testing
+import Foundation
+@testable import SoloDeveloperTraining
+
+struct ConcurrencyIssueTests {
+
+ @Test("Wallet Task.detached Concurrent - race condition 재현")
+ func walletTaskDetachedRaceCondition() async throws {
+ let user = await User(nickname: "test")
+ let wallet = await user.wallet
+ let iterations = 10000
+
+ await withTaskGroup(of: Void.self) { group in
+ for _ in 0..