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 | -| 사진 | image | image | image | image | -| MBTI | ISTJ | ENFJ | ISTP | INTP | - -## Wiki -- [Hmm wiki](https://github.com/boostcampwm2025/iOS01-Hmm/wiki) - -## 그룹 프로젝트 목표 -- 일하지 않고 추억 만들기 - - '놀이'처럼 생각하기 - - 재미를 잃지 않기 -- 배포 가능한 수준의 기능 개발 완료하기 - - App 다운로드 100회 달성하기 - - 사용자 피드백 받아보기 -스크린샷 2025-12-09 오전 1 23 31 - - -## 주간 일정 -### 1주차 (기획 주간) -image - -## 개발 규칙 -- 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 라벨 -image - -## 이슈, PR 템플릿 -- 이슈 템플릿 +| 사진 | image |image| image | image| +| 역할 | 팀원 | 🤴🏻 팀장 | 팀원 | 팀원 | + +# 🎮 개발자 키우기 +image + +# 📝 개요 + +> `개발자 키우기`는 백수에서 시작해 월드클래스 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가지 미니게임 +| 코드짜기 | 언어 맞추기 | 버그 피하기 | 데이터 쌓기 | +| --- | --- | --- | --- | +|image|image|image|image| +| 반복적인 화면 터치(탭)를 통해 재산을 획득할 수 있습니다. | 올바른 언어 아이콘을 매칭 터치하여 재산을 획득할 수 있습니다. | CoreMotion 자이로 센서를 활용하여 기기 기울여 재산을 획득할 수 있습니다. | SpriteKit 물리 엔진을 기반으로 타이밍에 맞춰 터치하여 재산을 획득할 수 있습니다. | -## 리뷰 요구사항 -``` +## 3. 피버 시스템 +- **3단계 피버 게이지**: 0~300%까지 노란색 → 주황색 → 빨간색으로 시각화 했습니다. +- **단계별 배율**: 100% 도달 시 x배, 200% 도달 시 y배, 300% 도달 시 z배 획득 할 수 있습니다. +- **게이지 변화**: 액션 성공 시 증가하고, n초마다 자동 감소합니다. + +| 0단계 | 1단계 | 2단계 | 3단계 | +| --- | --- | --- | --- | +|image|image|image|image| + + +## 4. 경제 시스템 + +| 스킬| 아이템 | 부동산 | +| --- | --- | --- | +|image|image|image| +| - 업무 4개 모드마다 초급/중급/고급의 스킬이 존재합니다.
- 레벨이 올라갈수록 각 업무의 액션 재산이 증가합니다. | - 커피, 박하스로 일시적 버프 효과를 획득합니다.
- 키보드, 마우스, 모니터, 의자 각각의 8등급의 강화 시스템이 존재합니다.
- 등급이 높아질수록 강화 성공 확률이 감소합니다. | - 길바닥 → 반지하 → … → 펜트하우스의 등급이 존재합니다.
- 배경을 변경할 수 있고, 부동산은 하나만 소유 가능합니다.| + + +## 5. 부가 콘텐츠 +| 퀴즈 | 미션 | 튜토리얼 | 설정 | +| --- | --- | --- | --- | +| image| image| image| image| +| 개발 밈과 관련된 퀴즈로 다이아 보상을 획득할 수 있습니다. | 다양한 목표 달성으로 지속적인 플레이를 보장하며 보상을 획득할 수 있습니다. | 게임 시스템의 기본적인 학습을 할 수 있습니다. | 사운드, 효과음, 햅틱에 대한 설정을 조절할 수 있습니다. | 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..