diff --git a/ARCHITECTURE_DIAGRAM.md b/ARCHITECTURE_DIAGRAM.md new file mode 100644 index 0000000..fe321f2 --- /dev/null +++ b/ARCHITECTURE_DIAGRAM.md @@ -0,0 +1,665 @@ +# Architecture & Flow Diagrams + +## System Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Cocos Creator Runtime │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ UI Layer (This Analysis) │ │ +│ ├──────────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ ┌────────────────┐ ┌──────────────┐ │ │ +│ │ │ main.ts │────────▶│ ViewManager │ │ │ +│ │ │ (Bootstrap) │ │ (Singleton) │ │ │ +│ │ └────────────────┘ └──────────────┘ │ │ +│ │ │ │ │ +│ │ Pages (All extend BaseView): │ │ │ +│ │ ┌─────────────────────────────────┼──────────────┐ │ │ +│ │ │ • PageLoading (init data) │ │ │ │ +│ │ │ • PageHome (hub) │ │ │ │ +│ │ │ • PageLevel (game - complex) ◀┘ │ │ │ +│ │ │ • PagePreviewLevels (list) │ │ │ +│ │ │ • PassModal (modal) │ │ │ +│ │ └────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌────────────────┐ ┌──────────────┐ │ │ +│ │ │ ToastManager │────────▶│ Toast │ │ │ +│ │ │ (Singleton) │ │ (Component) │ │ │ +│ │ └────────────────┘ └──────────────┘ │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Data Layer (External - Not Analyzed) │ │ +│ ├──────────────────────────────────────────────────────────┤ │ +│ │ • AuthManager • LevelDataManager │ │ +│ │ • StorageManager • UserAssetsManager │ │ +│ │ • ShareManager • WxSDK │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Page State Machine + +``` + ┌─────────────────────────────┐ + │ Application Starts │ + └──────────────┬──────────────┘ + │ + ▼ + ┌──────────────────────┐ + │ main.onLoad() │ + │ Registers all pages │ + └──────────┬───────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ PageLoading displayed │ + │ - Load auth + levels │ + │ - Sync progress │ + │ - Check share code │ + └──────────────┬───────────────┘ + │ + ┌──────────────┴──────────────┐ + │ Normal Mode │ Share Mode │ + ▼ ▼ │ + ┌──────────────────┐ ┌───────────────┐ │ + │ PageHome │ │ PageLevel │ │ + │ [Start] [PK] │ │ (in share) │ │ + └────┬─────┬───────┘ └───────┬───────┘ │ + │ │ │ │ + ┌────────▼─┐ └────────────┬──────┴────────┘ + │ │ │ + ▼ ▼ ▼ +PageLevel PageWrite (Repeat levels) +(game) Levels │ + │ (create) ▼ + │ │ PassModal + │ ▼ [Share] + │ PagePreview │ + │ Levels ▼ + │ (verify) Back to Home + │ │ + │ ▼ + │ PassModal + │ [Next/Share] + │ │ + └──────────┼─────────┐ + │ │ + ┌────▼──┐ ┌───▼────┐ + │ Next │ │ Share │ + │ Loop │ │ to WX │ + └───────┘ └────────┘ +``` + +--- + +## Page Stack Visualization + +### Session Trace + +``` +INITIALIZATION: +┌────────────────────────────────────────────┐ +│ Stack: [] │ +│ (Nothing loaded yet) │ +└────────────────────────────────────────────┘ + │ + ▼ main.onLoad() +┌────────────────────────────────────────────┐ +│ Stack: [] │ +│ ViewManager + pages registered in registry │ +└────────────────────────────────────────────┘ + │ + ▼ PageLoading.start() → ViewManager.open('PageHome') +┌────────────────────────────────────────────┐ +│ Stack: [PageHome] │ +│ PageHome._doShow() called │ +└────────────────────────────────────────────┘ + +USER CLICKS "START GAME": + ▼ PageHome._onStartGameClick() → ViewManager.open('PageLevel') +┌────────────────────────────────────────────┐ +│ Stack: [PageHome, PageLevel] │ +│ PageHome._doHide() called │ +│ PageLevel._doShow() called │ +└────────────────────────────────────────────┘ + +COMPLETES LEVEL: + ▼ PageLevel shows PassModal (not in stack, drawn on top) +┌────────────────────────────────────────────┐ +│ Stack: [PageHome, PageLevel] │ +│ UI Layer: [PageLevel, PassModal@z999] │ +│ PassModal shown with instantiate() │ +└────────────────────────────────────────────┘ + +CLICKS "NEXT LEVEL": + ▼ PassModal closed, PageLevel.nextLevel() +┌────────────────────────────────────────────┐ +│ Stack: [PageHome, PageLevel] │ +│ PageLevel loads next level config │ +│ PassModal destroyed │ +└────────────────────────────────────────────┘ + +CLICKS BACK FROM PageLevel: + ▼ PageLevel.onIconSettingClick() → ViewManager.back() +┌────────────────────────────────────────────┐ +│ Stack: [PageHome] │ +│ PageLevel._doHide() called │ +│ PageLevel destroyed (cache=true, not kept) │ +│ PageHome._doShow() called │ +└────────────────────────────────────────────┘ +``` + +--- + +## Component Communication Diagram + +``` + ┌─────────────────────┐ + │ ViewManager │ + │ (Singleton) │ + └──────────┬──────────┘ + │ + ┌──────────────┼──────────────┐ + │ │ │ + ▼ ▼ ▼ + open() back() replace() + │ │ │ + │ │ │ + ┌───────────┴────┐ ┌───┴────┐ ┌──┴───────┐ + │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ +PageHome PageLevel PassModal (back) (new page) + │ │ │ + │ ├─ Timer ────┐ + │ │ │ + │ ├─ Points │ + │ │ ↑ │ + │ │ │ │ + ▼ ▼ │ ▼ +ToastMgr Hints │ PassModal + │ │ │ + ├──────┤ └──▶ Share to WX + │ │ + Unlock Earn Points + Consume (UserAssets) + (check hasPoints) +``` + +--- + +## Data Flow: Level Completion + +``` +USER ENTERS ANSWER: +┌─────────────────────────────────────────────────────────┐ +│ PageLevel.onSubmitAnswer() │ +└─────────────────────────────────────────────────────────┘ + │ + ├─ getAnswer() ──▶ [Retrieve from EditBox] + │ + ├─ Compare with _currentConfig.answer + │ + ▼ + ┌────────────────────────────────────────┐ + │ CORRECT ANSWER │ + └────────────────────────────────────────┘ + │ + ├─ _isTransitioning = true [Prevent double-submit] + │ + ├─ stopCountdown() + │ + ├─ playSuccessSound() + │ + ├─ Calculate: timeSpent = 60 - _countdown + │ + ├─┐ Normal Mode: + │ │ + │ └─▶ UserAssetsManager.earnPoint(levelId, timeSpent) + │ │ + │ ▼ + │ └─ UPDATE SERVER + LOCAL + │ └─ Get points amount + │ └─ PageLevel.updatePointsLabel() + │ + ├─┐ Share Mode: + │ │ + │ └─▶ ShareManager.reportLevelProgress(...) + │ │ + │ ▼ + │ └─ SEND TO SERVER (fire-and-forget) + │ + ├─ _showPassModal() + │ │ + │ ├─ instantiate(passModalPrefab) + │ │ + │ ├─ Set z-index = 999 (topmost) + │ │ + │ ├─ Add to Canvas + │ │ + │ └─ Call passModal.onViewLoad() + onViewShow() + │ + └─ WAIT FOR USER: + │ + ├─ [Next Level] ──▶ PassModal closed + │ │ + │ ▼ + │ nextLevel() + │ │ + │ ┌────────┴────────┐ + │ │ No More Levels │ Next Level + │ ▼ ▼ + │ BACK HOME Load & Display + │ Restart Timer + │ + └─ [Share] ──▶ WxSDK.shareAppMessage() + │ + └─ Modal stays open +``` + +--- + +## Hint Unlocking Flow + +``` +USER CLICKS UNLOCK BUTTON: +┌──────────────────────────────────────────────┐ +│ PageLevel.onUnlockClue(index: 2 or 3) │ +└──────────────────────────────────────────────┘ + │ + ├─ Check: _isUnlocking == true? + │ └─ YES: Return immediately (prevent double-click) + │ └─ NO: Continue + │ + ├─ Check: StorageManager.hasPoints()? + │ └─ NO: Show toast "积分不足!" + │ └─ YES: Continue + │ + ├─ Set: _isUnlocking = true + │ + ├─ await UserAssetsManager.consumePoint(levelId, index) + │ │ + │ ├─ ASYNC: Contact server/storage + │ │ + │ └─ Returns: success boolean + │ + ├─ If success: + │ │ + │ ├─ updatePointsLabel() + │ │ + │ ├─ playClickSound() + │ │ + │ ├─ hideUnlockButton(index) + │ │ + │ ├─ showClue(index) + │ │ + │ └─ setClue(index, config.clueN) + │ + ├─ If failed: + │ │ + │ └─ Show toast "积分不足!" + │ + └─ Finally: _isUnlocking = false +``` + +--- + +## Timer Sequence Diagram + +``` +Timeline: 1s 2s 3s ... 59s 60s 61s + │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ +countdown: 60 59 58 1 0 (ended) + │ │ │ │ │ +display: "60s" "59s" "58s" ......... "1s" "0s" + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ + +LEVEL LOAD: + │ + └─ startCountdown() + │ + ├─ _countdown = 60 + ├─ _isTimeUp = false + └─ schedule(onCountdownTick, 1.0) [Repeat every 1 sec] + │ + ▼ (Every 1 second) + onCountdownTick() + │ + ├─ if (_isTimeUp) return + │ + ├─ _countdown-- (60→59→58...→0) + │ + ├─ updateClockLabel() [Display: "59s"] + │ + └─ if (_countdown <= 0): + │ + ├─ _isTimeUp = true + │ + ├─ stopCountdown() + │ └─ unschedule(onCountdownTick) + │ + └─ onTimeUp() + │ + └─ playFailSound() + +ANSWER SUBMITTED (Correct): + │ + └─ showSuccess() + │ + ├─ stopCountdown() + │ └─ Timer stops, no more ticks + │ + └─ _showPassModal() + +OR: + +ANSWER SUBMITTED (Wrong): + │ + └─ Timer continues running... + │ + └─ User keeps playing +``` + +--- + +## Input Box Creation & Management + +``` +LEVEL LOADS: + │ + └─ _applyLevelConfig(config) + │ + └─ createSingleInput(config.answer.length) + │ + ├─ clearInputNodes() + │ │ + │ └─ Destroy old input nodes + │ (If level changed) + │ + ├─ Hide inputTemplate + │ + ├─ instantiate(inputTemplate) + │ + ├─ Set properties: + │ │ + │ ├─ active = true + │ ├─ name = 'singleInput' + │ ├─ position = (0, 0, 0) + │ │ + │ └─ Get EditBox component + │ │ + │ ├─ placeholder = "(Xlength个字)" + │ ├─ maxLength = answerLength + │ ├─ string = "" + │ │ + │ └─ Listen events: + │ ├─ TEXT_CHANGED → onInputTextChanged() + │ └─ EDITING_DID_ENDED → onInputEditingEnded() + │ + ├─ Calculate width: + │ └─ Math.min(600, Math.max(200, length*60+40)) + │ + ├─ Set UITransform contentSize to width x 100 + │ + ├─ Adjust underline width to match + │ + ├─ Add to inputLayout + │ + └─ Store in _inputNodes[0] + +USER ENTERS TEXT: + │ + ├─ EditBox updates string property + └─ TEXT_CHANGED event fires + +USER SUBMITS: + │ + ├─ getAnswer() + │ │ + │ └─ _inputNodes[0].getComponent(EditBox).string.trim() + │ + └─ Compare with config.answer + +USER NAVIGATES AWAY: + │ + └─ onViewDestroy() → clearInputNodes() + │ + └─ Destroy all input nodes +``` + +--- + +## Share Challenge Mode Flow + +``` +LAUNCH FROM WeChat LINK: + │ + └─ PageLoading.start() + │ + └─ _startPreload() + │ + ├─ Initialize auth + levels (parallel) + │ + ├─ WxSDK.getShareCodeFromLaunch() + │ │ + │ └─ Extract from wx.getLaunchOptionsSync().query + │ + ├─ if shareCode exists AND loginSuccess: + │ │ + │ └─ ShareManager.instance.joinShare(shareCode) + │ │ + │ ├─ ASYNC: Fetch share session from server + │ │ + │ ├─ Get shared level set + │ │ + │ └─ Return: success boolean + │ + ├─ if (joinSuccess): + │ │ + │ ├─ Set progress = 1 + │ │ + │ ├─ ViewManager.open('PageLevel', { + │ │ params: { shareMode: true } + │ │ }) + │ │ + │ └─ Destroy self (PageLoading) + │ + └─ else (normal flow) + │ + └─ Preload PageHome → Open PageHome + +IN SHARE MODE (PageLevel): + │ + ├─ onViewLoad(): + │ │ + │ ├─ params = getParams() + │ ├─ _isShareMode = params?.shareMode === true + │ │ + │ ├─ if shareMode: + │ │ └─ currentLevelIndex = 0 (start at first) + │ │ + │ └─ initLevel() loads from ShareManager, not LevelDataManager + │ + ├─ showSuccess(): + │ │ + │ ├─ if normal mode: + │ │ └─ earn points + │ │ + │ └─ if share mode: + │ │ + │ └─ ShareManager.reportLevelProgress(levelId, true, timeSpent) + │ │ + │ └─ FIRE-AND-FORGET (no await) + │ + ├─ nextLevel(): + │ │ + │ ├─ if NOT shareMode: + │ │ └─ StorageManager.onLevelCompleted() [Save progress] + │ │ + │ ├─ Check level count: + │ │ │ + │ │ ├─ Share: ShareManager.getShareLevelCount() + │ │ └─ Normal: LevelDataManager.getLevelCount() + │ │ + │ ├─ if (allLevelsComplete): + │ │ │ + │ │ ├─ if shareMode: + │ │ │ │ + │ │ │ ├─ ShareManager.clearShareMode() + │ │ │ │ + │ │ │ └─ ViewManager.replace('PageHome') + │ │ │ + │ │ └─ if normal: + │ │ └─ ViewManager.back() + │ │ + │ └─ else (more levels): + │ └─ Load next level + │ + └─ Back to PageHome (replaces, clears share state) +``` + +--- + +## ViewManager Caching Mechanism + +``` +OPEN 'PageHome' (first time): + │ + └─ ViewManager.open('PageHome') + │ + ├─ Check cache: _viewCache.get('PageHome') + │ └─ Cache miss (undefined) + │ + ├─ _instantiateView('PageHome', prefab) + │ │ + │ ├─ instantiate(prefab) + │ │ + │ ├─ Get BaseView component + │ │ + │ ├─ Set viewId, config, params + │ │ + │ ├─ Add to container + │ │ + │ ├─ Call view.onViewLoad() ◄─── Called once + │ │ + │ ├─ if (config.cache === true): ◄─── PageHome has cache:true + │ │ └─ _viewCache.set('PageHome', view) + │ │ + │ └─ _showView(view) + │ │ + │ └─ view._doShow() + │ └─ view.onViewShow() ◄─── Called now + │ + └─ Stack: [PageHome] + +OPEN 'PageLevel': + │ + └─ ViewManager.open('PageLevel') + │ + ├─ PageHome._doHide() + │ └─ view.onViewHide() ◄─── First hide + │ + └─ (Same as above for PageLevel) + │ + └─ Stack: [PageHome, PageLevel] + +BACK FROM 'PageLevel': + │ + └─ ViewManager.back() + │ + ├─ Pop stack: PageLevel + │ + ├─ PageLevel._doHide() + │ └─ view.onViewHide() + │ + ├─ Decide: shouldDestroy? + │ │ + │ └─ PageLevel has cache:true + │ └─ shouldDestroy = false (keep cached) + │ + ├─ Get current: PageHome + │ + ├─ PageHome._doShow() + │ └─ view.onViewShow() ◄─── Called again + │ + └─ Stack: [PageHome] + Caches: {PageHome, PageLevel} + +OPEN 'PageLevel' AGAIN (from cache): + │ + └─ ViewManager.open('PageLevel') + │ + ├─ Check cache: _viewCache.get('PageLevel') + │ └─ Cache hit! (PageLevel instance) + │ + ├─ _showView(cachedView) + │ │ + │ ├─ PageHome._doHide() + │ │ + │ └─ cachedView._doShow() + │ └─ view.onViewShow() ◄─── Called again (2nd time) + │ onViewLoad() NOT called again ◄─── Important! + │ + └─ Stack: [PageHome, PageLevel] + Same instance reused +``` + +--- + +## Error Handling Paths + +``` +LEVEL LOADING ERROR: + │ + ├─ initLevel() + │ │ + │ └─ await LevelDataManager.ensureLevelReady(index) + │ │ + │ └─ Returns null (error) + │ + └─ Log warning, return early + │ + └─ UI stays as-is (might be blank) + +INSUFFICIENT POINTS: + │ + └─ onUnlockClue() + │ + ├─ StorageManager.hasPoints() → false + │ + └─ ToastManager.show("积分不足!") + │ + └─ User sees notification, unlock blocked + +DOUBLE-SUBMIT PREVENTION: + │ + ├─ onSubmitAnswer() + │ │ + │ └─ if (_isTransitioning) return ◄─── Blocks multiple calls + │ + └─ showSuccess() + │ + └─ _isTransitioning = true ◄─── Set to block + │ + └─ After modal shown/closed + └─ (Would be reset on nextLevel) + +DOUBLE-CLICK UNLOCK: + │ + └─ onUnlockClue() + │ + └─ if (_isUnlocking) return ◄─── Blocks rapid clicks + │ + └─ _isUnlocking = true at start + └─ Finally: _isUnlocking = false ◄─── Reset after async +``` + diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md index 0703e50..055d7b6 100644 --- a/QUICK_REFERENCE.md +++ b/QUICK_REFERENCE.md @@ -1,196 +1,348 @@ -# QUICK REFERENCE - Game Points/Score System +# UI Components - Quick Reference Guide -## 🎮 What is the "Currency"? -**LIVES** - Not traditional points/coins, but a renewable "health" resource. +## Component Overview -## 📊 Lives Management -``` -Storage Key: "game_lives" (localStorage) -Default: 10 -Min Value: 0 -Max Value: ∞ (no limit) - -Methods: -├─ getLives() → Returns current lives -├─ setLives(n) → Set specific value -├─ consumeLife() → Deduct 1 life -├─ addLife() → Add 1 life -├─ hasLives() → Check if > 0 -└─ resetLives() → Reset to 10 -``` - -## 🎯 How Lives Are Spent -| Action | Cost | Where | -|--------|------|-------| -| Unlock Clue 2 | 1 Life | PageLevel → onUnlockClue(2) | -| Unlock Clue 3 | 1 Life | PageLevel → onUnlockClue(3) | -| **TOTAL PER LEVEL** | **0-2 Lives** | Depends on player choice | - -## 🏆 How Lives Are Earned -| Action | Reward | Where | -|--------|--------|-------| -| Complete a Level | +1 Life | PageLevel → showSuccess() | -| Wrong Answer | 0 | No penalty | -| Time Up | 0 | No penalty | - -## 📈 Level Progression -``` -Storage Key: "game_progress" (localStorage) -Structure: -{ - currentLevelIndex: number, // 0-based, current level - maxUnlockedLevelIndex: number // 0-based, highest reached -} - -Default: { currentLevelIndex: 0, maxUnlockedLevelIndex: 0 } -``` - -### Progression Rules: -1. **Level 1 always unlocked** - Start here -2. **Beat Level N** → currentLevel becomes N+1 -3. **Beat Level N** → maxUnlocked becomes max(maxUnlocked, N) -4. **Can replay earlier levels** - But always progress forward - -### Methods: -``` -getCurrentLevelIndex() → Get current (0-based) -setCurrentLevelIndex(n) → Jump to level -getMaxUnlockedLevelIndex() → Get highest reached -isLevelUnlocked(n) → Check if playable -onLevelCompleted(n) → Save win + progress -resetProgress() → Reset to level 1 -``` - -## 🎨 Level Data (from API) -**Endpoint:** `https://ilookai.cn/api/v1/wechat-game/levels` - -```typescript -ApiLevelData { - id: string, // UUID - level: number, // Level number (1-based display) - imageUrl: string, // Main puzzle image - hint1: string, // Free clue - hint2: string, // Costs 1 life - hint3: string, // Costs 1 life - answer: string, // The answer (case-sensitive, trimmed) - sortOrder: number // Sort order -} -``` - -## ⏱️ Gameplay Mechanics - -### Time Limit -- **Duration:** 60 seconds per level -- **On Timeout:** Play fail sound, game doesn't end -- **After Timeout:** Can still submit answers - -### Input System -- **Type:** Single text box (not per-character) -- **Processing:** Trimmed, case-sensitive comparison -- **Max Length:** Based on answer length - -### Win Condition -``` -input.trim() === answer -↓ -Play success sound → Stop timer → Award +1 life -→ Show PassModal → Save progress -``` - -### Lose Condition -``` -input.trim() !== answer -↓ -Play fail sound → Vibrate → Show toast -→ Lives unchanged → Can retry -``` - -## 🎁 Rewards & Penalties -| Event | Lives Change | Other Effects | -|-------|--------------|---------------| -| Correct Answer | +1 | Play success sound, show modal | -| Wrong Answer | 0 | Play fail sound, vibrate, toast | -| Unlock Clue | -1 | Show clue content | -| Time Up | 0 | Play fail sound, countdown stops | -| Level Complete | Already +1ed | Save progress, move to next | - -## 🔄 Economy Balance -``` -Starting Inventory: 10 lives - -Without Hints: +1 life/level → Infinite -With 1 Hint/Level: 0 lives/level → Stable -With 2 Hints/Level: -1 life/level → Finite (10-20 levels) - -Net Formula: newLives = oldLives - hintsUsed + 1 (on win) -``` - -## 📡 API Integration -``` -LevelDataManager { - API_URL: "https://ilookai.cn/api/v1/wechat-game/levels" - TIMEOUT: 8000ms - RETRY_COUNT: 2 - - Calls: - ├─ initialize() → Load all level metadata + image for level 1 - ├─ ensureLevelReady(n) → Load specific level image - ├─ preloadNextLevel(n) → Silently preload level n+1 - └─ getLevelConfig(n) → Get cached level data -} -``` - -## 📁 Storage Schema -``` -localStorage: { - "game_lives": "10", - "game_progress": "{\"currentLevelIndex\":0,\"maxUnlockedLevelIndex\":0}" -} -``` - -## 🌐 WeChat Integration -``` -Features Used: -├─ WxSDK.initShare() → Enable sharing -├─ WxSDK.shareAppMessage() → Share to friend with level query param -├─ WxSDK.vibrateLong() → 400ms vibration on error -└─ WxSDK.vibrateShort() → 15ms vibration on click -``` - -## 🔑 Key Files -``` -StorageManager.ts → Lives & progress persistence -LevelDataManager.ts → API & image loading -PageLevel.ts → Main game logic -PageLoading.ts → App initialization -PassModal.ts → Victory screen -ViewManager.ts → Page navigation -WxSDK.ts → WeChat APIs -``` - -## ⚙️ Constants -``` -DEFAULT_LIVES 10 -MIN_LIVES 0 -LEVEL_TIME_LIMIT 60 seconds -LIFE_PER_HINT 1 -LIFE_PER_WIN 1 -API_TIMEOUT 8000ms -API_RETRY_COUNT 2 - -Game Title "写英语" (Write English) -Share Query Format "level=" -``` - -## 🚨 No Implementation For: -- User Authentication (wx.login) -- Backend Progress Save -- Ads/Monetization -- Leaderboards -- Analytics -- Premium Life Refills -- Difficulty Levels +| Component | Purpose | Extends | Key Responsibilities | +|-----------|---------|---------|----------------------| +| **main.ts** | App bootstrap | Component | Register pages, init ViewManager & ToastManager | +| **PageLoading.ts** | Splash screen | Component | Load data, sync progress, detect share code | +| **PageHome.ts** | Landing page | BaseView | Navigation hub (game/PK buttons) | +| **PageLevel.ts** | Game level | BaseView | Core gameplay (timer, hints, scoring, input) | +| **PagePreviewLevels.ts** | Level preview | BaseView | Scrollable list of selected levels | +| **PassModal.ts** | Completion modal | BaseView | Next level & share buttons, success sound | +| **Toast.ts** | Notification | Component | Display message with fade animation | +| **ToastManager.ts** | Toast system | Singleton | Centralized toast creation & display | +| **BaseView.ts** | Page base class | Component | Lifecycle interface for all pages | +| **ViewManager.ts** | Page manager | Singleton | Navigation, stacking, caching, lifecycle | --- -**In Summary:** Players earn/spend LIVES by unlocking clues (-1 each) or winning levels (+1 each). Progress is saved locally with streak tracking. The economy encourages players to solve without hints to maximize lives. +## Lifecycle Stages + +### Page Lifecycle (BaseView) +``` +onViewLoad() → onViewShow() → onViewHide() → onViewDestroy() + (once) (each show) (each hide) (on destroy) +``` + +### App Startup Flow +``` +main.onLoad() + ↓ (Initialize ViewManager + register pages) +PageLoading.start() + ↓ (Load auth + levels in parallel, check share code) +PageHome (normal) OR PageLevel (share mode) +``` + +--- + +## Key Data Flows + +### Level Progression +``` +PageLevel loads config → Displays image + clue1 → User enters answer + ↓ (correct) → Earn points → Show PassModal → Next level + ↓ (wrong) → Show toast → Continue playing + ↓ (timeout) → Play fail sound +``` + +### Hint Unlocking +``` +User clicks unlock button → Check points available + ↓ (yes) → Consume point from UserAssetsManager + ↓ → Hide unlock button, show clue + ↓ (no) → Show "insufficient points" toast +``` + +### Share Challenge Mode +``` +WeChat link → PageLoading detects share code + ↓ → ShareManager.joinShare(code) + ↓ → Play through shared levels (doesn't save local progress) + ↓ → Report progress to server + ↓ → Return to PageHome +``` + +--- + +## Component Properties Summary + +### PageLevel (Most Complex) +**UI Elements:** +- inputLayout, submitButton, inputTemplate, actionNode +- iconSetting (back button) +- tipsLayout, tipsItem1/2/3 (clues) +- unLockItem2/3 (unlock buttons) +- clockLabel (timer), liveLabel (points) +- mainImage (level image) + +**Audio:** +- clickAudio, successAudio, failAudio + +**Internal State:** +- _countdown (60 sec timer) +- _isTimeUp (timer expired flag) +- _currentConfig (level data) +- _isTransitioning (prevent double-submit) +- _isUnlocking (prevent double-click unlock) +- _passModalNode (modal reference) +- _isShareMode (shared vs normal) + +**Key Methods:** +- onViewLoad/Show/Hide/Destroy (lifecycle) +- initLevel() (async load config) +- createSingleInput(length) (input box) +- onUnlockClue(index) (consume points for hints) +- onSubmitAnswer() (check answer) +- showSuccess() (correct: earn points, show modal) +- showError() (wrong: toast + vibration) +- nextLevel() (progress to next) +- startCountdown() / onCountdownTick() (60s timer) + +--- + +## Points & Scoring System + +**Earning Points:** +- `UserAssetsManager.earnPoint(levelId, timeSpent)` +- Called in PageLevel.showSuccess() +- Time spent = 60 - countdown + +**Consuming Points:** +- `UserAssetsManager.consumePoint(levelId, hintIndex)` +- Called in PageLevel.onUnlockClue() +- Check `StorageManager.hasPoints()` before consuming + +**Displaying Points:** +- `StorageManager.getPoints()` → Display as "x {points}" +- Called in PageLevel.updatePointsLabel() + +--- + +## Timer System + +**60-Second Countdown:** +- Starts when level loads: `startCountdown()` +- Ticks every 1 second: `onCountdownTick()` +- Displayed as: "60s", "59s", ..., "0s" +- On timeout: plays fail sound, doesn't end game + +**Stop Timer:** +- `stopCountdown()` called when: + - Answer submitted (correct) + - Page destroyed + - All levels completed + +--- + +## Hint/Clue System + +**3 Clues Per Level:** +1. **Clue 1** - Always visible (free) +2. **Clue 2** - Unlock button, costs points +3. **Clue 3** - Unlock button, costs points + +**Display:** +- Text in tipsItem1/2/3 nodes +- Hidden if locked (tipsItem.active = false) +- Unlock button visible if locked + +**Unlock Flow:** +1. User clicks unLockItem2 or unLockItem3 +2. Check points: `StorageManager.hasPoints()` +3. Consume: `UserAssetsManager.consumePoint(levelId, index)` +4. Hide button, show clue, update UI + +--- + +## Navigation Stack + +**Structure:** +- LIFO stack managed by ViewManager +- Each open() adds to stack +- back() pops from stack +- replace() swaps top + +**Example Session:** +``` +[PageHome] + ↓ open PageLevel +[PageHome, PageLevel] + ↓ open PassModal (or inline) +[PageHome, PageLevel, PassModal?] + ↓ close/back +[PageHome] +``` + +**Methods:** +- `ViewManager.open(viewId, options?)` - Push to stack +- `ViewManager.back()` - Pop from stack, show previous +- `ViewManager.replace(viewId, options?)` - Pop current, push new + +--- + +## Caching Strategy + +**Cached Views (default):** +- Instance reused on re-open +- onViewLoad() called once +- onViewShow() called each time +- Memory persists + +**Non-Cached Views:** +- New instance each time +- onViewLoad() called each time +- Memory released on close + +**Configuration:** +```typescript +// In main.ts registration +ViewManager.instance.register('PageHome', { + prefab: pageHomePrefab, + cache: true, // ← Reuse instance + zIndex: 0 // ← Layer depth +}); +``` + +--- + +## Toast System + +**Show Toast:** +```typescript +ToastManager.show("消息内容", 2000); // duration in ms +``` + +**Initialization (main.ts):** +```typescript +ToastManager.instance.init(toastPrefab, canvasNode); +``` + +**Toast Behavior:** +- Creates Toast instance from prefab +- Displays for duration (default 2000ms) +- Fades out over 300ms +- Auto-destroys + +--- + +## WeChat Integration + +**Share Initialization (PageHome):** +```typescript +WxSDK.initShare({ + title: '写英语', + imageUrl: '', + query: '' +}); +``` + +**Share in Challenge (PassModal):** +```typescript +WxSDK.shareAppMessage({ + title: '快来一起玩这款游戏吧', + query: `level={levelIndex}` +}); +``` + +**Share Code Detection (PageLoading):** +```typescript +const shareCode = WxSDK.getShareCodeFromLaunch(); +if (shareCode) { + const joined = await ShareManager.instance.joinShare(shareCode); + // Launch PageLevel in share mode +} +``` + +**Other WeChat Features:** +- `WxSDK.vibrateLong()` - Haptic feedback on wrong answer +- `WxSDK.isWechat()` - Check if running in WeChat +- `checkPrivacySetting()` - Check privacy authorization +- `requirePrivacyAuthorize()` - Prompt for privacy consent + +--- + +## Error Handling & Edge Cases + +**Level Loading:** +- Try cache first, then async load +- Preload next level without blocking +- Handle missing config gracefully + +**Point Consumption:** +- Check availability before consuming +- Show toast if insufficient +- Prevent double-click with flag + +**Answer Submission:** +- Prevent multiple submissions with transition flag +- Trim whitespace from answer +- Case-sensitive comparison + +**Page Navigation:** +- Share mode: clears share state before returning home +- Normal mode: simple back() navigation +- Modal: stays on top (z-index 999) + +--- + +## Common Tasks + +### Add Toast +```typescript +ToastManager.show('提示信息'); +``` + +### Navigate to Page +```typescript +ViewManager.instance.open('PageLevel'); // Push +ViewManager.instance.back(); // Pop +ViewManager.instance.replace('PageHome');// Swap +``` + +### Get Current Level +```typescript +const index = StorageManager.getCurrentLevelIndex(); +``` + +### Check/Consume Points +```typescript +if (StorageManager.hasPoints()) { + await UserAssetsManager.consumePoint(levelId, hintIndex); +} +``` + +### Start Game +```typescript +// From PageHome +ViewManager.instance.open('PageLevel'); +``` + +### Create & Share Challenge +```typescript +// User flow: +PageHome → [PK Button] → PageWriteLevels [Select 6] + → PagePreviewLevels [Verify] + → PassModal [Share Button] + → WxSDK.shareAppMessage() +``` + +--- + +## File Size & Complexity + +| File | Lines | Complexity | +|------|-------|-----------| +| main.ts | 92 | Simple (init only) | +| PageLoading.ts | 141 | Medium (parallel loading, sync) | +| PageHome.ts | 119 | Simple (two buttons) | +| PageLevel.ts | 823 | **Very High** (core game logic) | +| PagePreviewLevels.ts | 247 | Medium (scroll list) | +| PassModal.ts | 155 | Simple (two buttons) | +| Toast.ts | 50 | Simple (fade animation) | +| ToastManager.ts | 59 | Simple (factory pattern) | +| BaseView.ts | 132 | Medium (lifecycle) | +| ViewManager.ts | 320 | High (stack navigation) | + +**Total: ~2,138 lines** | **Focal Point: PageLevel.ts (38% of code)** + diff --git a/UI_COMPONENTS_INDEX.md b/UI_COMPONENTS_INDEX.md new file mode 100644 index 0000000..0a49fa2 --- /dev/null +++ b/UI_COMPONENTS_INDEX.md @@ -0,0 +1,489 @@ +# UI Components Complete Analysis - Index + +## 📚 Documentation Files Created + +This analysis consists of three comprehensive documents: + +### 1. **UI_COMPONENT_ANALYSIS.md** (Main Reference) +**1,424 lines | 45 KB** +- Detailed breakdown of all 10 files +- Complete method signatures and implementations +- Lifecycle method documentation +- Data manager interactions +- Game flow explanations +- Key concepts and patterns + +**Best for:** Deep dives into specific components, understanding implementation details + +### 2. **QUICK_REFERENCE.md** (Developer Cheat Sheet) +**~400 lines | 8.6 KB** +- Component overview table +- Lifecycle stages summary +- Key data flows (visual) +- Component properties summary +- Points & scoring system quick look +- Timer system overview +- Hint/clue system +- Navigation stack examples +- Toast system usage +- WeChat integration quick reference +- Common tasks & code snippets +- File complexity metrics + +**Best for:** Quick lookups, copy-paste code patterns, understanding overall architecture + +### 3. **ARCHITECTURE_DIAGRAM.md** (Visual Reference) +**~1,000 lines | 26 KB** +- System architecture diagram +- Page state machine +- Page stack visualization with session traces +- Component communication diagrams +- Data flow diagrams (level completion, hint unlocking) +- Timer sequence diagrams +- Input box creation flow +- Share challenge mode flow +- ViewManager caching mechanism +- Error handling paths + +**Best for:** Understanding flow, tracing execution, visual learners + +--- + +## 🎯 Component Summary + +### Core Infrastructure +- **main.ts** (92 lines) - App bootstrap, register pages and managers +- **BaseView.ts** (132 lines) - Abstract page lifecycle interface +- **ViewManager.ts** (320 lines) - Stack-based page navigation system + +### UI Pages (All extend BaseView) +- **PageLoading.ts** (141 lines) - Splash screen, load data, detect share code +- **PageHome.ts** (119 lines) - Landing page hub with game/PK buttons +- **PageLevel.ts** (823 lines) ⭐ **Most Complex** - Main game with timer, hints, scoring +- **PagePreviewLevels.ts** (247 lines) - Scrollable list of selected levels +- **PassModal.ts** (155 lines) - Completion modal with next/share buttons + +### Notification System +- **Toast.ts** (50 lines) - Individual toast notification with fade animation +- **ToastManager.ts** (59 lines) - Singleton manager for toast creation + +**Total: ~2,138 lines across 10 files** + +--- + +## 🔑 Key Topics Quick Index + +### Game Mechanics +| Topic | Location | Key Method | +|-------|----------|-----------| +| 60-second countdown | PageLevel | startCountdown() / onCountdownTick() | +| Hint/clue system (3 levels) | PageLevel | onUnlockClue(index) | +| Answer verification | PageLevel | onSubmitAnswer() / getAnswer() | +| Level progression | PageLevel | nextLevel() | +| Points earning | PageLevel | showSuccess() + UserAssetsManager.earnPoint() | +| Points spending | PageLevel | onUnlockClue() + UserAssetsManager.consumePoint() | + +### UI Management +| Topic | Location | Key Method | +|-------|----------|-----------| +| Page navigation | ViewManager | open() / back() / replace() | +| Page lifecycle | BaseView | onViewLoad/Show/Hide/Destroy | +| Page caching | ViewManager | _viewCache Map | +| Page stacking | ViewManager | _viewStack array (LIFO) | +| Toast notifications | ToastManager | show(content, duration) | + +### Special Features +| Topic | Location | Key Method | +|-------|----------|-----------| +| Share challenge mode | PageLevel + ShareManager | _isShareMode flag | +| WeChat integration | PageHome, PassModal, WxSDK | initShare() / shareAppMessage() | +| Server progress sync | PageLoading | _syncProgressFromServer() | +| Level preloading | PageLevel | preloadNextLevel() | + +--- + +## 📊 Complexity Breakdown + +``` +PageLevel.ts (823 lines - 38% of total code) +├─ Input management +├─ Timer system +├─ Hint unlocking +├─ Answer validation +├─ Point interactions +├─ Audio effects +├─ Modal handling +└─ Share mode support + +ViewManager.ts (320 lines - 15% of total code) +├─ Page registration +├─ Stack management +├─ Instance caching +├─ Lifecycle coordination +└─ Error handling + +All Other Components (995 lines - 47% of total code) +``` + +--- + +## 🎮 Game Flow Overview + +``` +User Launches App + ↓ +main.ts registers pages & managers + ↓ +PageLoading loads auth + levels (parallel) + ↓ + ┌─ Detect Share Code ──→ Enter Share Mode (PageLevel) + │ ↓ + │ Play Levels + │ ↓ + │ Return Home + │ + └─ Normal Path ──→ Open PageHome + ↓ + ┌──────┴──────┐ + ↓ ↓ + Play Game Create Challenge + (PageLevel) (PageWriteLevels) + ↓ ↓ + Win Level PagePreviewLevels + ↓ ↓ + PassModal PassModal/Share + ↓ ↓ + Next/Back Share to WeChat +``` + +--- + +## 💾 Data Manager Interactions + +### By Page + +**PageLoading** +- AuthManager.initialize() +- LevelDataManager.initialize() +- StorageManager.getMaxUnlockedLevelIndex() +- StorageManager.onLevelCompleted() +- ShareManager.joinShare() +- WxSDK.getShareCodeFromLaunch() + +**PageHome** +- WxSDK.initShare() +- checkPrivacySetting() +- requirePrivacyAuthorize() + +**PageLevel** (Most interactions) +- LevelDataManager (load, preload, get count) +- StorageManager (get/set progress, get points) +- UserAssetsManager (earn/consume points) +- ShareManager (share mode, report progress) +- WxSDK (vibration, sharing) +- ToastManager (notifications) +- ViewManager (navigation) + +**PagePreviewLevels** +- LevelDataManager.ensureLevelReady() + +**PassModal** +- WxSDK.shareAppMessage() + +--- + +## ⚠️ Important Flags & Preventing Issues + +### Double-Submission Prevention +```typescript +// In PageLevel.onSubmitAnswer() +if (this._isTransitioning) return; // Blocks rapid re-submission +``` + +### Double-Click Unlock Prevention +```typescript +// In PageLevel.onUnlockClue() +if (this._isUnlocking) return; // Blocks rapid hint unlocking +``` + +### Share Mode Detection +```typescript +// In PageLevel.onViewLoad() +this._isShareMode = params?.shareMode === true; +``` + +### Timer Running Flag +```typescript +// In PageLevel.onCountdownTick() +if (this._isTimeUp) return; // Prevents countdown after timeout +``` + +--- + +## 🔄 Page Lifecycle Details + +### First-Time Open +1. ViewManager.open() called +2. Prefab instantiated +3. onViewLoad() called (do heavy init here) +4. Page added to stack +5. onViewShow() called + +### Re-open (if cached) +1. ViewManager.open() called +2. Cache hit found +3. Previous page onViewHide() called +4. This page onViewShow() called (not onViewLoad!) +5. Page already in stack (or re-added) + +### Close/Back +1. ViewManager.back() called +2. Current page onViewHide() called +3. Current page destroyed (if not cached) +4. Previous page onViewShow() called + +### Destroy +1. onViewDestroy() called +2. All listeners removed +3. Resources cleaned up +4. Node destroyed + +--- + +## 📝 Common Code Patterns + +### Open Page with Parameters +```typescript +ViewManager.instance.open('PageLevel', { + params: { shareMode: true }, + onComplete: (view) => { + console.log('PageLevel opened'); + }, + onError: (err) => { + console.error('Failed to open:', err); + } +}); +``` + +### Get Page Parameters +```typescript +onViewLoad(): void { + const params = this.getParams(); + if (params?.shareMode) { + // Handle share mode + } +} +``` + +### Show Toast Notification +```typescript +ToastManager.show('提示信息', 2000); // 2 second display +``` + +### Check & Consume Points +```typescript +if (StorageManager.hasPoints()) { + const success = await UserAssetsManager.consumePoint(levelId, hintIndex); + if (success) { + this.updatePointsLabel(); + } +} +``` + +### Schedule & Unschedule +```typescript +startCountdown(): void { + this.schedule(this.onCountdownTick, 1); // Every 1 second +} + +stopCountdown(): void { + this.unschedule(this.onCountdownTick); // Stop callback +} +``` + +--- + +## 🐛 Known Patterns to Watch + +### Caching Implications +- Pages with cache:true keep their onViewLoad() state +- onViewShow() is called each time, even if cached +- If you modify state in onViewLoad(), it persists! +- Solution: Reset state in onViewShow() if needed + +### Share Mode Edge Cases +- Share mode doesn't save local progress +- Share mode uses ShareManager instead of LevelDataManager +- Share mode clears state on return to home +- Must check _isShareMode before saving + +### Modal Positioning +- PassModal instantiated with z-index 999 (topmost) +- Added to Canvas root node (not PageLevel child) +- This allows full-screen coverage with proper Widget alignment + +### Timer Edge Cases +- Timer continues even if page is hidden +- Must stop timer on destroy or completion +- onTimeUp() plays sound but doesn't end game automatically +- Wrong answer doesn't stop timer + +--- + +## 🎯 Tips for Modifications + +### Adding New Hint Types +1. Add hint4 config to RuntimeLevelConfig +2. Add unLockItem4 property to PageLevel +3. Add tipsItem4 property to PageLevel +4. Call showUnlockButton(4) in _applyLevelConfig() +5. Handle in onUnlockClue() switch/case + +### Adding Page Analytics +1. Add tracking in onViewLoad() (first visit) +2. Add tracking in onViewShow() (each visit) +3. Use page viewId for identification +4. Track user actions in event handlers + +### Changing Point System +1. Modify UserAssetsManager (not shown) +2. Update updatePointsLabel() display format +3. Adjust hasPoints() check if needed +4. Test unlock flow with new values + +### Adjusting Timer +1. Change `_countdown = 60` to desired seconds +2. Update display frequency if needed +3. Adjust point calculation if time-based +4. Test timeout behavior + +--- + +## 📖 How to Use This Documentation + +**For a new developer:** +1. Start with QUICK_REFERENCE.md for overview +2. Read UI_COMPONENT_ANALYSIS.md sections for specific pages +3. Reference ARCHITECTURE_DIAGRAM.md for flow understanding + +**For debugging:** +1. Find error in component name +2. Look up methods in UI_COMPONENT_ANALYSIS.md +3. Check flow in ARCHITECTURE_DIAGRAM.md +4. Use QUICK_REFERENCE.md for code patterns + +**For adding features:** +1. Understand game flow in QUICK_REFERENCE.md +2. Find where to hook in UI_COMPONENT_ANALYSIS.md +3. Check data flows in ARCHITECTURE_DIAGRAM.md +4. Copy patterns from QUICK_REFERENCE.md + +**For optimization:** +1. Note complexity metrics in QUICK_REFERENCE.md +2. Identify bottlenecks in PageLevel.ts +3. Check async operations in PageLoading.ts +4. Review caching in ViewManager.ts + +--- + +## 🔗 Cross-References + +### Frequently Referenced Files +- **PageLevel.ts** - Most complex, used from PageHome and PageLoading +- **ViewManager.ts** - Used by all pages for navigation +- **StorageManager** - Used by PageLoading and PageLevel +- **UserAssetsManager** - Used by PageLevel for points +- **LevelDataManager** - Used by PageLoading and PageLevel + +### Integration Points +``` +main.ts (startup) + ↓ +PageLoading (data loading) + ├─ AuthManager + ├─ LevelDataManager + └─ ShareManager + ↓ +PageHome (navigation hub) + ├─ PageLevel (game) + │ ├─ StorageManager + │ ├─ UserAssetsManager + │ ├─ WxSDK + │ └─ PassModal (modal) + │ └─ WxSDK + │ + └─ PageWriteLevels (not analyzed) + └─ PagePreviewLevels + └─ LevelDataManager +``` + +--- + +## 📊 File Statistics + +| Metric | Value | +|--------|-------| +| Total Lines | 2,138 | +| Total Files Analyzed | 10 | +| Most Complex File | PageLevel.ts (823 lines, 38%) | +| Most Reused Manager | ViewManager | +| Total Data Managers Used | 7+ | +| UI Components | 5 pages + 2 utilities | +| Lifecycle Methods | 4 per BaseView | +| Key Patterns | Stack navigation, caching, events | + +--- + +## 🚀 Next Steps for Development + +1. **Run the game** and trace through the flows documented here +2. **Add logging** to key methods to understand real execution order +3. **Modify a page** to get familiar with lifecycle +4. **Create new page** following BaseView pattern +5. **Add new feature** using documented patterns + +--- + +## 📞 Quick Troubleshooting + +**Page not showing?** +- Check ViewManager.open() called +- Verify prefab has BaseView component +- Check registration in main.ts + +**Points not updating?** +- Verify UserAssetsManager call succeeds +- Check updatePointsLabel() is called +- Test StorageManager.getPoints() returns value + +**Timer not stopping?** +- Check stopCountdown() called on completion +- Verify unschedule() works in Cocos +- Test onDestroy calls stopCountdown() + +**Modal not appearing?** +- Check z-index set to 999 +- Verify added to Canvas root +- Test instantiate() succeeds +- Check onViewLoad/Show called manually + +**Share mode broken?** +- Verify share code detected in PageLoading +- Check _isShareMode set properly +- Test ShareManager.joinShare() succeeds +- Verify clearShareMode() called on exit + +--- + +## ✅ Analysis Complete + +All 10 UI component files have been thoroughly analyzed and documented. + +**Total Documentation: 3 comprehensive guides** +- **UI_COMPONENT_ANALYSIS.md** - Deep technical reference (1,424 lines) +- **QUICK_REFERENCE.md** - Developer cheat sheet (~400 lines) +- **ARCHITECTURE_DIAGRAM.md** - Visual flow diagrams (~1,000 lines) +- **This file** - Navigation & index (this document) + +**Ready for:** Development, debugging, feature implementation, onboarding + diff --git a/UI_COMPONENT_ANALYSIS.md b/UI_COMPONENT_ANALYSIS.md new file mode 100644 index 0000000..4d4c756 --- /dev/null +++ b/UI_COMPONENT_ANALYSIS.md @@ -0,0 +1,1424 @@ +# Cocos Creator Project - UI Component Analysis + +This document provides a comprehensive analysis of the UI component files in the "写英语" (Write English) Cocos Creator game project. + +--- + +## 1. **main.ts** - Main Entry Script + +**Location:** `assets/main.ts` + +**Class:** `main extends Component` + +### Purpose +- Main initialization script that bootstraps the entire application +- Responsible for initializing the ViewManager singleton +- Registers all page prefabs with their configurations +- Initializes the ToastManager for notifications + +### Lifecycle Methods +- **onLoad()** - Executes before `start()` to ensure ViewManager is initialized early + - Calls `_initViewManager()` to set up the view system + +### Key Methods +- **_initViewManager()** - Core initialization logic: + - Initializes `ViewManager` with the canvas node as container + - Registers 5 pages with configurations (cache and z-index): + - **PageHome** (cache: true, zIndex: 0) - Landing page + - **PageLevel** (cache: true, zIndex: 1) - Game level page + - **PageWriteLevels** (cache: true, zIndex: 2) - Create challenge levels + - **PagePreviewLevels** (cache: true, zIndex: 3) - Preview selected levels + - **PagePKData** (cache: true, zIndex: 3) - Challenge data page + - Initializes `ToastManager` with toast prefab and canvas reference + +### UI Properties (Serialized in Editor) +``` +@property pageHomePrefab: Prefab +@property pageLevelPrefab: Prefab +@property pageWriteLevelsPrefab: Prefab +@property pagePreviewLevelsPrefab: Prefab +@property pagePKDataPrefab: Prefab +@property toastPrefab: Prefab +``` + +### Data Managers Used +- ViewManager (initialization) +- ToastManager (initialization) + +### Game Flow +Entry point → Initializes ViewManager → Registers all pages → PageLoading is likely instantiated separately to show splash/loading screen + +--- + +## 2. **PageLoading.ts** - Loading/Splash Screen + +**Location:** `assets/PageLoading.ts` + +**Class:** `PageLoading extends Component` + +### Purpose +- Handles application initialization and resource loading +- Manages user authentication and level data preloading +- Displays loading progress (0-100%) +- Handles WeChat share code detection for shared challenges +- Syncs user progress from server with local storage + +### Lifecycle Methods +- **start()** - Called when scene starts + - Immediately calls `_startPreload()` + +### Key Methods + +**_startPreload() - Main initialization pipeline:** +1. Resets progress bar to 0% +2. Runs login and level loading **in parallel** using `Promise.all()`: + - `AuthManager.instance.initialize()` - User authentication + - `LevelDataManager.instance.initialize()` - Level data loading (provides progress updates) + - Level loading occupies 0-80% of progress bar +3. Logs login success/failure +4. Checks for login success: + - If login successful → calls `_syncProgressFromServer()` + - Maps server `completedLevelIds` to local `currentLevelIndex` + - Takes max of (server progress, local progress) to prevent regression +5. Detects WeChat share code: + - Calls `WxSDK.getShareCodeFromLaunch()` to extract share code from launch parameters + - If share code exists AND login successful → calls `ShareManager.instance.joinShare(shareCode)` + - If join succeeds → directly opens **PageLevel** with `shareMode: true` + - Destroys loading page after PageLevel opens +6. Normal flow (no share code): + - Preloads **PageHome** (80-100% of progress) + - Opens PageHome and destroys itself + +**_syncProgressFromServer() - Server progress sync:** +- Gets `completedLevelIds` from server +- Finds max completed index: `LevelDataManager.getMaxCompletedIndex(completedIds)` +- Compares with local max: `StorageManager.getMaxUnlockedLevelIndex()` +- If server > local → calls `StorageManager.onLevelCompleted(maxCompletedIndex)` +- Ensures user never loses progress across devices + +**_updateProgress(progress: number)** - Updates progress bar (0-1 range) + +**_updateStatusLabel(message: string)** - Updates status text display + +### UI Components +``` +@property progressBar: ProgressBar +@property statusLabel: Label +``` + +### Data Managers Used +- **AuthManager** - User authentication (login, completedLevelIds) +- **LevelDataManager** - Level loading with progress callbacks +- **StorageManager** - Local progress storage +- **ShareManager** - Handle shared challenge joins +- **WxSDK** - WeChat integration (launch params, share code detection) +- **ViewManager** - Open pages + +### Flow Control +- Login + Level loading (parallel) → Server sync → Share code check → Page navigation → Self-destroy + +### Key States +- Progress tracking (0.0 to 1.0) +- Login success/failure handling +- Share code detection and join flow +- Normal vs. share challenge modes + +--- + +## 3. **PageHome.ts** - Home/Landing Page + +**Location:** `assets/prefabs/PageHome.ts` + +**Class:** `PageHome extends BaseView` + +### Purpose +- Main landing page displayed after loading complete +- Provides navigation to: + - Game levels (single-player) + - PK/Challenge creation (multiplayer) +- Handles WeChat SDK initialization and privacy authorization + +### Lifecycle Methods +- **onViewLoad()** - First-time initialization: + - Calls `_initButtons()` + - Calls `_initWxShare()` + - Calls `_checkPrivacyAuthorization()` + +- **onViewShow()** - Called every time page is shown (after being hidden) + - Logs view show event + +- **onViewHide()** - Called when page is covered or closed + - Logs view hide event + +- **onViewDestroy()** - Called when page is destroyed: + - Removes button event listeners + +### UI Event Handlers + +**_initButtons() - Button setup:** +```typescript +startGameBtn - "Start Game" button click → _onStartGameClick() +pkBtn - "PK/Challenge" button click → _onPkClick() +``` + +**_onStartGameClick() - Start game handler:** +- Logs button click +- Opens **PageLevel** (game page) +- Navigation: PageHome → PageLevel + +**_onPkClick() - Challenge/PK handler:** +- Logs button click +- Opens **PageWriteLevels** (challenge creation page) +- Navigation: PageHome → PageWriteLevels + +### UI Properties +``` +@property(Node) startGameBtn +@property(Node) pkBtn +``` + +### Data Managers Used +- ViewManager (page navigation) +- WxSDK (share initialization, privacy checks) + +### WeChat Integration + +**_initWxShare() - Share configuration:** +```typescript +WxSDK.initShare({ + title: '写英语' (title) + imageUrl: '' (empty - uses default) + query: '' (empty - filled per-share) +}) +``` + +**_checkPrivacyAuthorization() - Privacy handling:** +- Checks if running in WeChat environment: `WxSDK.isWechat()` +- Calls `checkPrivacySetting()` to check privacy authorization status +- If not authorized → calls `requirePrivacyAuthorize()` to prompt user +- Catches exceptions and logs warnings + +### Flow +PageLoading → PageHome → [startGameBtn] → PageLevel + → [pkBtn] → PageWriteLevels + +--- + +## 4. **PageLevel.ts** - Game Level/Challenge Page + +**Location:** `assets/prefabs/PageLevel.ts` + +**Class:** `PageLevel extends BaseView` + +### Purpose +- Main game level playing interface +- Handles gameplay mechanics: + - Display level image/clue + - Accept user answer input + - Verify answer correctness + - Manage hint unlocking with point consumption + - Level progression +- Supports both normal and shared challenge modes +- Manages 60-second countdown timer + +### Lifecycle Methods + +**onViewLoad() - Initialization:** +1. Reads parameters (checks for `shareMode: true`) +2. Sets `currentLevelIndex`: + - Share mode: start at index 0 + - Normal mode: restore from `StorageManager.getCurrentLevelIndex()` +3. Updates points display: `updatePointsLabel()` +4. Initializes icon setting button +5. Initializes unlock buttons (for hints 2 & 3) +6. Initializes submit button +7. **Async:** Loads level config → Starts 60-second countdown + +**onViewShow() - Shown callback:** +- Updates points display + +**onViewHide() - Hidden callback:** +- Logs event + +**onViewDestroy() - Cleanup:** +- Clears input nodes +- Stops countdown +- Closes pass modal +- Removes all event listeners + +### UI Properties +``` +// Layout +@property(Node) inputLayout - Input box container +@property(Node) submitButton - Answer submission +@property(Node) inputTemplate - Input box template +@property(Node) actionNode - Action buttons area +@property(Node) iconSetting - Settings/back button +@property(Node) tipsLayout - Clues layout +@property(Node) mainImage - Main clue image display + +// Clues (3 hints) +@property(Node) tipsItem1 - Clue 1 (default unlocked) +@property(Node) tipsItem2 - Clue 2 (unlockable) +@property(Node) tipsItem3 - Clue 3 (unlockable) + +// Unlock buttons +@property(Node) unLockItem2 - Unlock button for clue 2 +@property(Node) unLockItem3 - Unlock button for clue 3 + +// Display labels +@property(Label) clockLabel - Countdown timer display +@property(Label) liveLabel - Points display (named "liveLabel" for compatibility) + +// Audio clips +@property(AudioClip) clickAudio +@property(AudioClip) successAudio +@property(AudioClip) failAudio + +// Pass modal +@property(Prefab) passModalPrefab +``` + +### Configuration Properties +``` +@property currentLevelIndex - Current level (synced from storage) +``` + +### Internal State +``` +_inputNodes: Node[] - Created input box nodes +_countdown: number - Countdown remaining (60 seconds) +_isTimeUp: boolean - Timer expired flag +_currentConfig: RuntimeLevelConfig - Current level data +_isTransitioning: boolean - Prevents double submit during transitions +_isUnlocking: boolean - Prevents double-click on unlock buttons +_passModalNode: Node - Pass modal instance +_isShareMode: boolean - True if in shared challenge mode +``` + +### Level Loading + +**initLevel() - Async level loading:** +- Share mode: loads from `ShareManager.instance.ensureShareLevelReady(levelIndex)` +- Normal mode: + 1. Tries cache: `LevelDataManager.getLevelConfig(levelIndex)` + 2. If not cached → loads: `LevelDataManager.ensureLevelReady(levelIndex)` +- Calls `_applyLevelConfig(config)` after loading + +**_applyLevelConfig(config) - Apply level configuration:** +1. Stores config in `_currentConfig` +2. Resets transition/time-up flags +3. Sets main image from `config.spriteFrame` +4. Sets clue 1 (default visible) from `config.clue1` +5. Hides clues 2 & 3 +6. Shows unlock buttons 2 & 3 +7. Creates input field based on answer length: `createSingleInput(config.answer.length)` +8. Updates countdown display +9. Preloads next level: + - Share mode: `ShareManager.instance.ensureShareLevelReady(nextIndex)` + - Normal mode: `LevelDataManager.preloadNextLevel(levelIndex)` + +### Input Box Management + +**createSingleInput(answerLength) - Single input field:** +- Creates one input field sized for full answer +- Sets placeholder: `(X个字)` (X characters) +- Sets max length to answer length +- Dynamically sizes width: `Math.min(600, Math.max(200, answerLength * 60 + 40))` +- Adjusts underline width to match +- Listens to TEXT_CHANGED and EDITING_DID_ENDED events + +**getAnswer() - Retrieves user input:** +- Gets text from single input field +- Trims whitespace +- Returns as string + +### Clue/Hint System + +**Clue Display (3 levels):** +- Clue 1: Always visible, free +- Clue 2: Requires point consumption to unlock +- Clue 3: Requires point consumption to unlock + +**Methods:** +- `setClue(index, content)` - Set clue text +- `showClue(index)` - Show clue item +- `hideClue(index)` - Hide clue item +- `showUnlockButton(index)` - Show unlock button +- `hideUnlockButton(index)` - Hide unlock button + +**onUnlockClue(index) - Unlock hint handler:** +1. Check if currently unlocking (prevent double-click): `_isUnlocking` +2. Check points availability: `hasPoints()` +3. If no points → show toast "积分不足,无法解锁提示!" (Insufficient points) +4. Call `UserAssetsManager.consumePoint(levelId, index)` (async) +5. If successful: + - Update points display: `updatePointsLabel()` + - Play click sound + - Hide unlock button + - Show clue + - Set clue content from config (clue2 or clue3) + +### Points/Score System + +**updatePointsLabel() - Display current points:** +- Gets points: `StorageManager.getPoints()` +- Updates label: `x {points}` + +**hasPoints() - Check if user has points:** +- Returns: `StorageManager.hasPoints()` + +**Data Manager Interactions:** +- `UserAssetsManager.consumePoint(levelId, hintIndex)` - Consume points for hint (async) +- `UserAssetsManager.earnPoint(levelId, timeSpent)` - Earn points on level completion (async) +- `StorageManager.getPoints()` - Get current points +- `StorageManager.hasPoints()` - Check if any points available + +### Countdown Timer System + +**startCountdown() - Start 60-second timer:** +- Sets `_countdown = 60` +- Sets `_isTimeUp = false` +- Updates display +- Schedules `onCountdownTick()` every 1 second + +**onCountdownTick() - Per-second callback:** +- Decrements `_countdown` +- Updates display +- Calls `onTimeUp()` when countdown reaches 0 + +**updateClockLabel() - Display countdown:** +- Shows: `{_countdown}s` + +**stopCountdown() - Stop timer:** +- Unschedules `onCountdownTick()` + +**onTimeUp() - Timer expiration:** +- Plays fail sound +- Can add game over logic + +### Answer Submission + +**onSubmitAnswer() - Submit button handler:** +1. Checks if config loaded and not in transition +2. Gets user answer: `getAnswer()` +3. Compares with correct answer: `_currentConfig.answer` +4. If match → calls `showSuccess()` +5. If mismatch → calls `showError()` + +**showSuccess() - Correct answer handler:** +1. Sets `_isTransitioning = true` (prevent double submit) +2. Stops countdown +3. Plays success sound +4. Calculates time spent: `60 - _countdown` +5. Normal mode: + - Calls `UserAssetsManager.earnPoint(levelId, timeSpent)` to earn points + - Updates points display +6. Share mode: + - Calls `ShareManager.reportLevelProgress(levelId, true, timeSpent)` (fire-and-forget) +7. Shows pass modal: `_showPassModal()` + +**_showPassModal() - Display completion modal:** +1. Checks if prefab exists +2. Checks if modal already showing (prevent duplicates) +3. Instantiates pass modal +4. Positions at screen center +5. Sets z-index to 999 (highest layer) +6. Adds to Canvas root node (for proper full-screen layout) +7. Gets PassModal component and: + - Calls `setParams({levelIndex: currentLevelIndex + 1})` + - Sets callbacks for next level and share + - Manually calls `onViewLoad()` and `onViewShow()` + +**_closePassModal() - Close pass modal:** +- Checks if valid +- Destroys node +- Sets to null + +**showError() - Wrong answer handler:** +1. Plays fail sound +2. Triggers device vibration: `WxSDK.vibrateLong()` +3. Shows toast: "答案错误,再试试吧!" (Wrong answer, try again!) + +### Level Progression + +**nextLevel() - Advance to next level:** +1. Normal mode: saves progress → `StorageManager.onLevelCompleted(currentLevelIndex)` +2. Increments `currentLevelIndex` +3. Checks if more levels exist: + - Share mode: `ShareManager.getShareLevelCount()` + - Normal mode: `LevelDataManager.getLevelCount()` +4. If all levels complete: + - Logs completion message + - Stops countdown + - Share mode: clears share mode → opens PageHome + - Normal mode: goes back (ViewManager.back()) +5. If more levels: + - Loads next level: `initLevel()` + - Restarts countdown: `startCountdown()` + +### Audio Management + +**playSound(clip) - Generic sound player:** +- Gets AudioSource component +- Plays clip one-shot + +**playClickSound()** - Play button click sound + +**playSuccessSound()** - Play level completion sound + +**playFailSound()** - Play wrong answer sound + +### Icon Setting Button (Settings/Back) + +**initIconSetting() - Bind settings button:** +- Binds TOUCH_END event to `onIconSettingClick()` + +**onIconSettingClick() - Back button handler:** +1. Plays click sound +2. Share mode: + - Clears share mode: `ShareManager.clearShareMode()` + - Replaces with PageHome: `ViewManager.replace('PageHome')` +3. Normal mode: + - Goes back to previous page: `ViewManager.back()` + +### Data Managers Used +- **LevelDataManager** - Load level configs, preload next level, get level count +- **StorageManager** - Get/set current level, save progress, get points +- **UserAssetsManager** - Consume/earn points for hints and completions +- **ShareManager** - Handle shared challenge mode +- **WxSDK** - Vibration feedback, WeChat API +- **ToastManager** - Show notification toasts +- **ViewManager** - Navigate to other pages + +### Game Flow +1. Load level data (async) +2. Display main image, clue 1, unlock buttons +3. Create input field +4. Start 60-second countdown +5. User enters answer +6. User can unlock clues 2 & 3 (points cost) +7. User submits answer +8. If correct: show pass modal, earn points, progress to next level +9. If incorrect: show error toast, continue playing +10. If timeout: show fail sound, can add game over + +### Share Challenge Flow +- Join via WeChat link with share code +- Load custom level set from ShareManager +- Play through shared levels +- Report progress back to server +- Return to home when complete + +--- + +## 5. **PagePreviewLevels.ts** - Challenge Level Preview + +**Location:** `assets/prefabs/PagePreviewLevels.ts` + +**Class:** `PagePreviewLevels extends BaseView` + +### Purpose +- Displays preview of 6 selected challenge levels in a vertical scrollable list +- Used after PageWriteLevels (where user selects 6 levels) +- Shows for each level: + - Cover image + - Hint 1 + - Hint 2 + - Answer +- Allows user to verify selected levels before sharing + +### Lifecycle Methods + +**onViewLoad() - First-time initialization:** +1. Calls `_initButtons()` - Bind back buttons +2. Calls `_initScrollView()` - Configure scroll view anchor + +**onViewShow() - Page shown:** +1. Calls `_buildList()` - Build level preview list from params + +**onViewHide() - Page hidden:** +- Logs event + +**onViewDestroy() - Cleanup:** +1. Calls `_offButtons()` - Remove button listeners +2. Calls `_clearList()` - Destroy all list items + +### UI Properties +``` +@property(Node) backBtn - Top-left back button +@property(Node) scrollView - ScrollView component container +@property(Node) listContent - Content node of scroll view (vertical list) +@property(Node) listTemplate - Template node for single level preview item +@property(Node) backButton - Bottom back button +@property(Node) pkTitle - Title label (editable) +``` + +### Layout Configuration +```typescript +LAYOUT = { + ITEM_HEIGHT: 300, // Height of each level preview item + SPACING_Y: 30, // Vertical spacing between items + PADDING_TOP: 20 // Top padding +} +``` + +### Button Handling + +**_initButtons() - Bind button events:** +- Binds both `backBtn` and `backButton` (redundant back buttons) +- Listens for Button.EventType.CLICK +- Calls `_onBackClick()` when clicked + +**_offButtons() - Remove event listeners:** +- Unbinds both back buttons + +**_onBackClick() - Back navigation:** +- Calls `ViewManager.instance.back()` to return to PageWriteLevels + +### List Building + +**_buildList() - Build preview list:** +1. Clears existing list: `_clearList()` +2. Gets params from ViewManager: + - `selectedIndices` - Array of level indices selected by user + - `shareTitle` - User-entered challenge title +3. Updates title label if provided +4. Logs selected level indices +5. Updates content height based on item count +6. For each selected level index: + - Creates item node: `_createItem(displayIndex)` + - Adds to content node + - Stores in `_itemNodes` + - Async loads data: `_loadLevelData(itemNode, levelIndex, displayIndex)` +7. Scrolls to top: `scrollView.scrollToTop(0)` + +**_clearList() - Clear existing items:** +- Destroys all nodes in `_itemNodes` +- Empties array + +**_updateContentSize(count) - Set list height:** +- Calculates total height: + ``` + = PADDING_TOP + + count * ITEM_HEIGHT + + (count - 1) * SPACING_Y + + PADDING_TOP + ``` +- Sets UITransform contentSize + +**_createItem(displayIndex) - Create single preview item:** +1. Instantiates template node +2. Sets active = true +3. Calculates y position (vertical list, negative y downward): + ``` + y = -(PADDING_TOP + displayIndex * (ITEM_HEIGHT + SPACING_Y) + ITEM_HEIGHT / 2) + ``` +4. Sets position and returns node + +**_loadLevelData(item, levelIndex, displayIndex) - Load level data:** +1. Async loads config: `LevelDataManager.ensureLevelReady(levelIndex)` +2. Fills item components: + - **LevelCover** (Sprite) ← spriteFrame + - **Tips1** (Label) ← `线索一:{clue1}` + - **Tips2** (Label) ← `线索二:{clue2}` + - **Answer** (Label) ← `答案:{answer}` + +### Data Managers Used +- **LevelDataManager** - Load level configs for preview +- **ViewManager** - Navigate back + +### Parameters (from PageWriteLevels) +```typescript +{ + selectedIndices: number[], // [0, 5, 10, 15, 20, 25] - 6 level indices + shareTitle: string // User-entered title +} +``` + +### Flow +PageHome → PageWriteLevels [Select 6 levels] → PagePreviewLevels [Verify] → Share/Back + +### Notes +- Uses vertical scrolling list with dynamic layout +- Displays all level data in preview format +- Title is customizable by user +- Soft loading pattern (preview page shows cached/async-loaded data) + +--- + +## 6. **PassModal.ts** - Level Completion Modal + +**Location:** `assets/prefabs/PassModal.ts` + +**Class:** `PassModal extends BaseView` + +### Purpose +- Modal dialog shown after level is completed successfully +- Provides two action buttons: + - "Next Level" - Progress to next level + - "Share to Friends" - Share challenge via WeChat +- Shows level completion confirmation +- Plays success sound + +### Interface Definition +```typescript +export interface PassModalCallbacks { + onNextLevel?: () => void; // Callback when next level clicked + onShare?: () => void; // Callback when share clicked +} +``` + +### Lifecycle Methods + +**onViewLoad() - First-time initialization:** +- Calls `_bindButtonEvents()` to attach button listeners + +**onViewShow() - Page shown:** +1. Calls `_updateWidget()` to set modal to full screen size +2. Calls `_playSuccessSound()` to play completion audio + +**onViewDestroy() - Cleanup:** +- Calls `_unbindButtonEvents()` to remove listeners + +### UI Properties +``` +@property(Node) nextLevelButton - "Next Level" button +@property(Node) shareButton - "Share" button +@property(Label) tipLabel - Optional tip label (e.g., "+1 life") +@property(AudioClip) successAudio - Completion sound effect +``` + +### Static Constants +```typescript +static readonly MODAL_Z_INDEX = 999 // Layer index for topmost display +``` + +### Internal State +``` +_callbacks: PassModalCallbacks - Button callbacks +_screenSize: Size | null - Cached screen dimensions +``` + +### Methods + +**setCallbacks(callbacks: PassModalCallbacks) - Set button callbacks:** +- Stores callbacks for next level and share events + +**_updateWidget() - Full-screen sizing:** +1. Gets visible screen size: `view.getVisibleSize()` +2. Caches size in `_screenSize` (avoid repeated calculations) +3. Sets UITransform content size to match screen + +**_bindButtonEvents() - Attach listeners:** +``` +nextLevelButton → TOUCH_END → _onNextLevelClick() +shareButton → TOUCH_END → _onShareClick() +``` + +**_unbindButtonEvents() - Remove listeners:** +- Checks `isValid` before removing (node may be destroyed) +- Removes both button listeners + +**_playSuccessSound() - Play completion audio:** +- Gets AudioSource component +- Plays `successAudio` one-shot + +**_onNextLevelClick() - Next level button:** +- Calls callback: `_callbacks.onNextLevel?.()` +- Usually triggers level progression in PageLevel + +**_onShareClick() - Share button:** +1. Calls `WxSDK.shareAppMessage()` with parameters: + ``` + title: '快来一起玩这款游戏吧' (Come play this game!) + query: `level={levelIndex}` + ``` +2. Calls callback: `_callbacks.onShare?.()` +3. Modal remains open (user can continue or close) + +### Data Managers Used +- **WxSDK** - Share app message to WeChat + +### Parameters (from PageLevel) +```typescript +{ + levelIndex: number // Current level index (1-based for display) +} +``` + +### Flow +Level Completed → Show PassModal → [Next Level] → Continue game + → [Share] → Share to WeChat, stay in modal + → Close → Return to PageLevel + +### Notes +- Modal stays open after share (user can click next level without closing) +- Uses full screen size with z-index 999 for modal effect +- Callbacks allow decoupling from page logic +- Screen size cached to avoid repeated view queries + +--- + +## 7. **Toast.ts** - Toast Notification Component + +**Location:** `assets/prefabs/Toast.ts` + +**Class:** `Toast extends Component` + +### Purpose +- Individual toast notification display component +- Shows brief notification message with fade-out animation +- Auto-destroys after display duration +- Supports custom duration + +### Lifecycle Methods + +**onLoad() - Initialization:** +1. Gets or creates UIOpacity component for fade animation +2. Stores reference in `_uiOpacity` + +### UI Properties +``` +@property(Label) contentLabel - Text label for message content +``` + +### Internal State +``` +_uiOpacity: UIOpacity | null - Component for opacity animation +``` + +### Methods + +**show(content: string, duration: number = 2000) - Display toast:** +1. Sets label text: `contentLabel.string = content` +2. Resets opacity to full: `_uiOpacity.opacity = 255` +3. Schedules fade-out after duration (converted to seconds): + ``` + scheduleOnce(_fadeOut, duration / 1000) + ``` + +**_fadeOut() - Fade-out animation:** +1. Tweens opacity from 255 → 0 over 0.3 seconds +2. On completion: destroys node +3. Uses Cocos tween system for smooth animation + +### Data Flow +ToastManager.show("message") → Creates Toast instance → Toast.show() → Display + Schedule fade → Destroy + +### Features +- Default display time: 2000ms (2 seconds) +- Fade duration: 300ms +- Auto-destroy after fade complete +- UIOpacity component for animation support + +--- + +## 8. **ToastManager.ts** - Toast Management Utility + +**Location:** `assets/scripts/utils/ToastManager.ts` + +**Class:** `ToastManager` (Singleton) + +### Purpose +- Singleton manager for centralized toast notification handling +- Creates and displays Toast instances in a container +- Provides static convenience methods for showing messages +- Initializes toast system once, uses throughout app + +### Singleton Pattern +```typescript +static get instance(): ToastManager +// Lazy instantiation on first access +``` + +### Lifecycle + +**init(prefab: Prefab, container?: Node) - Initialize manager:** +- Stores toast prefab reference +- Sets container (defaults to Canvas if not provided) +- Should be called once in main.ts during app initialization + +**show(content: string, duration?: number) - Show toast:** +1. Validates prefab and container are initialized +2. Instantiates toast prefab +3. Adds to container as child +4. Gets Toast component from node +5. Calls `Toast.show(content, duration)` to display + +### Static Methods +```typescript +static show(content: string, duration?: number = 2000) +// Convenience method: ToastManager.show("message") +``` + +### Internal State +``` +_prefab: Prefab | null - Toast prefab (set during init) +_container: Node | null - Parent node for toast instances +``` + +### Data Managers Used +- None (utility class) + +### Usage Example +```typescript +// In ToastManager initialization (main.ts) +ToastManager.instance.init(toastPrefab, canvasNode); + +// Anywhere in app +ToastManager.show("积分不足!", 2000); +``` + +### Flow +ToastManager.init() [once] → ToastManager.show() [many times] +Each show() → instantiate → add to container → display → fade → destroy + +--- + +## 9. **BaseView.ts** - Base View Class + +**Location:** `assets/scripts/core/BaseView.ts` + +**Class:** `BaseView extends Component` + +### Purpose +- Abstract base class for all pages/views in the game +- Defines page lifecycle interface for consistent behavior +- Manages view state (showing, parameters) +- Handles Cocos lifecycle integration + +### Interface Definition +```typescript +export interface ViewConfig { + prefab: Prefab; // Prefab asset reference + cache?: boolean; // Cache instance (true = reuse, false = destroy) + zIndex?: number; // Layer depth (higher = on top) +} + +export interface ViewOptions { + params?: any; // Data to pass to page + onComplete?: (view: BaseView) => void; // Success callback + onError?: (err: Error) => void; // Error callback +} +``` + +### Properties + +**Public:** +``` +viewId: string - Unique identifier +config: ViewConfig - Configuration from ViewManager +isShowing: boolean - Currently visible flag +_params: any - View parameters +``` + +### Lifecycle Methods (To Override) + +**onViewLoad() - First-time initialization:** +- Called when page is first created +- Initialize UI, bind events, load data +- Default: empty implementation + +**onViewShow() - Page becomes visible:** +- Called when page is opened or shown +- Update UI based on current state +- Default: empty implementation + +**onViewHide() - Page becomes hidden:** +- Called when page is closed or covered +- Pause animations, release resources +- Default: empty implementation + +**onViewDestroy() - Page destruction:** +- Called when page is being destroyed +- Cleanup resources, remove listeners +- Default: empty implementation + +### Public Methods + +**setParams(params: any) - Store view parameters:** +- Stores data passed from caller +- Used before showing view + +**getParams(): any - Retrieve view parameters:** +- Returns stored params +- Used by view to read initialization data + +### Internal Methods (Called by ViewManager) + +**_doShow() - Execute show logic:** +1. Check if already showing (prevent double-show) +2. Set `isShowing = true` +3. Set `node.active = true` +4. Call `onViewShow()` + +**_doHide() - Execute hide logic:** +1. Check if already hidden +2. Set `isShowing = false` +3. Call `onViewHide()` +4. Set `node.active = false` + +**_doDestroy() - Execute destroy logic:** +1. Mark as destroyed (prevent double-call) +2. Call `node.destroy()` + +### Cocos Lifecycle Integration + +**onDestroy() - Cocos lifecycle (called by engine):** +1. Check if not already marked destroyed +2. If currently showing: call `onViewHide()` +3. Call `onViewDestroy()` + +### Internal State +``` +_destroyed: boolean - Destroyed flag (prevent double-call) +``` + +### Usage Pattern +```typescript +@ccclass('PageExample') +export class PageExample extends BaseView { + onViewLoad(): void { + // Initialize UI + } + + onViewShow(): void { + // Page shown + } + + onViewHide(): void { + // Page hidden + } + + onViewDestroy(): void { + // Cleanup + } +} +``` + +### Data Managers Used +- None (base class provides only framework) + +### Inheritance Hierarchy +- PageHome extends BaseView +- PageLevel extends BaseView +- PagePreviewLevels extends BaseView +- PassModal extends BaseView + +--- + +## 10. **ViewManager.ts** - View/Page Management System + +**Location:** `assets/scripts/core/ViewManager.ts` + +**Class:** `ViewManager` (Singleton) + +### Purpose +- Centralized page management system +- Handles: + - Page registration and configuration + - Page lifecycle (create, show, hide, destroy) + - Navigation (open, back, replace) + - Page stacking (history navigation) + - Instance caching for reusable pages + - Preloading support + +### Singleton Pattern +```typescript +static get instance(): ViewManager +// Lazy-instantiated on first access +``` + +### Data Structures + +**Registered Views:** +```typescript +_registeredViews: Map +// Maps viewId → configuration +``` + +**View Stack:** +```typescript +_viewStack: BaseView[] +// LIFO stack for navigation history +``` + +**View Cache:** +```typescript +_viewCache: Map +// Stores instances when cache: true in config +``` + +### Lifecycle Methods + +**init(container: Node) - Initialize manager:** +- Sets container node (usually Canvas) +- Called once in main.ts during startup +- Must be called before opening any pages + +### Registration Methods + +**register(viewId: string, config: ViewConfig) - Register single page:** +1. Validates not already registered (logs error if duplicate) +2. Applies defaults: + ``` + cache: true (default) + zIndex: 0 (default) + ``` +3. Stores in `_registeredViews` + +**registerAll(views: Record) - Batch register:** +- Calls `register()` for each view + +### Page Opening + +**open(viewId: string, options?: ViewOptions) - Open page:** +1. Validates container initialized +2. Validates page registered +3. Checks cache: + - If cached instance exists and valid → calls `_showView()` + - Otherwise → calls `_instantiateView()` + +**_instantiateView(viewId, prefab, options) - Create new instance:** +1. Instantiates prefab: `instantiate(prefab)` +2. Gets BaseView component from node +3. Validates component exists (logs error if missing) +4. Sets view properties: + - `viewId` - page identifier + - `config` - page configuration + - params from options +5. Sets z-index: `setSiblingIndex(config.zIndex)` +6. Adds to container +7. Calls `onViewLoad()` on page +8. Caches if `config.cache === true` +9. Calls `_showView()` to display + +**_showView(view, options) - Display page:** +1. Gets current view: `getCurrentView()` +2. If different view showing → calls `_doHide()` on it +3. Updates params if provided +4. Adds to stack if not already there: `_viewStack.push(view)` +5. Calls `view._doShow()` to activate +6. Calls completion callback: `options.onComplete?.(view)` + +### Navigation + +**back() - Return to previous page:** +- Alias for `close()` +- Pops stack, hides current, shows previous + +**close(options?: {destroy?: boolean}) - Close current page:** +1. Pops view from stack +2. Determines if should destroy: + ``` + shouldDestroy = options?.destroy ?? !currentView.config?.cache + ``` + (Default: destroy if not cached) +3. Calls `_hideAndDestroyView()` +4. Gets next view from stack +5. If exists → calls `_doShow()` on it + +**replace(viewId: string, options?: ViewOptions) - Replace current page:** +1. Gets current view from top of stack +2. Pops and removes from stack +3. Destroys based on cache setting +4. Opens new page: `open(viewId, options)` + +### Utility Methods + +**_hideAndDestroyView(view, shouldDestroy) - Hide and optionally destroy:** +1. Calls `view._doHide()` to hide +2. If `shouldDestroy`: + - Removes from cache: `_viewCache.delete()` + - Calls `view._doDestroy()` to destroy node + +**getCurrentView(): BaseView | null - Get active page:** +- Returns top of stack (or null if empty) + +**getViewStack(): BaseView[] - Get copy of stack:** +- Returns [..._viewStack] (copy prevents external modification) + +**clearAll() - Clear all pages:** +1. Pops and destroys all stack pages +2. Destroys all cached pages +3. Clears caches + +### Preloading + +**preload(viewId, onProgress?, onComplete?) - Preload page:** +- Note: Main bundle assets already loaded, so this is mostly for compatibility +- Immediately callbacks with 100% progress and complete + +**preloadAll(viewIds, onProgress?, onComplete?) - Batch preload:** +- Preloads all provided view IDs +- Accumulates progress (0-1) +- Calls complete when all done + +### Data Flow + +**Opening First Page (from PageLoading):** +``` +PageLoading.start() + → ViewManager.open('PageHome') + → _instantiateView('PageHome', prefab) + → instantiate prefab + → getComponent(BaseView) + → call onViewLoad() + → cache instance + → _showView() + → push to stack + → call _doShow() + → node.active = true + → onViewShow() +``` + +**Navigating Between Pages:** +``` +PageHome.startGameBtn.click() + → ViewManager.open('PageLevel') + → _instantiateView or _showView (if cached) + → _showView() + → getCurrentView() = PageHome + → PageHome._doHide() + → push PageLevel to stack + → PageLevel._doShow() +``` + +**Going Back:** +``` +PageLevel.onIconSettingClick() + → ViewManager.back() + → close() + → pop from stack = PageLevel + → PageLevel._doHide() + → destroy or cache PageLevel + → getCurrentView() = PageHome + → PageHome._doShow() +``` + +### Stack Example + +**Session Flow:** +``` +Initial: [] + +After loading PageHome: +[PageHome] + +Open PageLevel from PageHome: +[PageHome, PageLevel] + +Open PassModal (modal, not in flow typically): +[PageHome, PageLevel, PassModal?] - or modal might use different system + +Back from PageLevel: +[PageHome] + +Open PageWriteLevels: +[PageHome, PageWriteLevels] + +Open PagePreviewLevels: +[PageHome, PageWriteLevels, PagePreviewLevels] + +Back from PagePreviewLevels: +[PageHome, PageWriteLevels] + +Back from PageWriteLevels: +[PageHome] +``` + +### Caching Behavior + +**Cached Pages (cache: true):** +- Instance reused on re-open +- `onViewLoad()` only called once (first open) +- `onViewShow()` called each time opened +- Memory stays allocated + +**Non-Cached Pages (cache: false):** +- New instance created each time +- `onViewLoad()` called each time +- Full cleanup on close +- Memory released + +### Error Handling +- Validates container initialized before operations +- Validates page registered before opening +- Validates BaseView component exists on prefab +- Logs errors (using Cocos `error()` function) +- Returns via error callback if provided + +### Data Managers Used +- None (infrastructure only) + +--- + +## Summary Table + +| File | Class | Purpose | Extends | Key Features | +|------|-------|---------|---------|--------------| +| main.ts | main | App initialization | Component | Initialize ViewManager, register pages | +| PageLoading.ts | PageLoading | Splash/loading screen | Component | Load data, sync progress, handle share code | +| PageHome.ts | PageHome | Landing page | BaseView | Navigation to game/PK, WeChat setup | +| PageLevel.ts | PageLevel | Game level | BaseView | Game mechanics, timer, hints, scoring | +| PagePreviewLevels.ts | PagePreviewLevels | Level preview | BaseView | Scrollable list, level verification | +| PassModal.ts | PassModal | Completion modal | BaseView | Next level/share buttons, success sound | +| Toast.ts | Toast | Notification | Component | Message display, fade animation | +| ToastManager.ts | ToastManager | Toast system | Singleton | Centralized toast management | +| BaseView.ts | BaseView | Page base class | Component | Lifecycle interface, parameter passing | +| ViewManager.ts | ViewManager | Page management | Singleton | Navigation, stacking, caching | + +--- + +## Game Flow Map + +``` +┌─────────────────────────────────────────────────────────────┐ +│ main.ts (Initialize) │ +│ Initialize ViewManager + Register All Pages │ +└──────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ PageLoading.ts (Splash) │ +│ • Load AuthManager + LevelDataManager (parallel) │ +│ • Sync server progress │ +│ • Check for share code (WeChat) │ +│ • Preload PageHome │ +└──────────────────────┬──────────────────────────────────────┘ + │ + ┌──────────────┼──────────────┐ + │ Normal Mode │ Share Mode │ + ▼ ▼ │ + ┌────────────────────────────┐ │ + │ PageHome.ts (Landing) │ │ + │ [Start Game] [PK/Create] │ │ + └──┬──────────────────────┬──┘ │ + │ │ │ + ▼ ▼ │ + ┌─────────────┐ ┌──────────────────┐ + │ PageLevel.ts│ │PageWriteLevels.ts│ (not shown) + │ (Game) │ │ (Create PK) │ + │ • Clues │ │ • Select 6 │ + │ • Timer │ │ • Input title │ + │ • Score │ └──────┬───────────┘ + │ • Submit │ │ + └──┬──────────┘ ▼ + │ ┌──────────────────┐ + │ │PagePreviewLevels │ + │ │ (Verify/Share) │ + │ └──────┬───────────┘ + │ │ + ├─────────────────────┤ + ▼ ▼ + ┌──────────────────────────────────┐ + │ PassModal.ts (Complete) │ + │ [Next Level] [Share to Friends]│ + │ • Earn points │ + │ • Play success sound │ + └──┬───────────────────────────────┘ + │ [Next Level] + ▼ or [Back] + (Loop to next level) +``` + +--- + +## Data Manager Integration + +``` +┌─────────────────────────────────────────────────────────┐ +│ Data Managers (Not in this analysis) │ +├─────────────────────────────────────────────────────────┤ +│ • AuthManager - User login, completedLevelIds │ +│ • LevelDataManager - Level configs, preloading │ +│ • StorageManager - Local progress, points │ +│ • UserAssetsManager - Point consumption/earning │ +│ • ShareManager - Share code, challenge data │ +│ • WxSDK - WeChat API, vibration, sharing │ +│ • ToastManager - Notifications │ +│ • ViewManager - Page navigation │ +└─────────────────────────────────────────────────────────┘ + +Used By Each Component: +┌────────────────────────────────────────────────┐ +│ PageLoading │ +│ • AuthManager.initialize() │ +│ • LevelDataManager.initialize() │ +│ • StorageManager.getMaxUnlockedLevelIndex() │ +│ • StorageManager.onLevelCompleted() │ +│ • ShareManager.joinShare() │ +│ • WxSDK.getShareCodeFromLaunch() │ +│ • ViewManager.preload/open() │ +└────────────────────────────────────────────────┘ + +┌────────────────────────────────────────────────┐ +│ PageLevel │ +│ • LevelDataManager (load, preload) │ +│ • StorageManager (progress, points) │ +│ • UserAssetsManager (consume, earn) │ +│ • ShareManager (share mode, report progress) │ +│ • WxSDK (vibration, sharing) │ +│ • ToastManager (notifications) │ +│ • ViewManager (navigation) │ +└────────────────────────────────────────────────┘ + +┌────────────────────────────────────────────────┐ +│ PagePreviewLevels │ +│ • LevelDataManager.ensureLevelReady() │ +│ • ViewManager.back() │ +└────────────────────────────────────────────────┘ +``` + +--- + +## Key Concepts + +### 1. **View Lifecycle Pattern** +- **onViewLoad**: First-time initialization +- **onViewShow**: Shown/activated +- **onViewHide**: Hidden/covered +- **onViewDestroy**: Destroyed/cleaned up + +### 2. **Caching Strategy** +- Pages can be cached (reused) or destroyed after close +- Cached pages call onViewLoad() once, onViewShow() multiple times +- Default: cache=true for most pages + +### 3. **Page Stacking** +- Stack-based navigation (LIFO) +- Each open() adds to stack +- back() pops from stack +- replace() swaps top of stack + +### 4. **Points/Scoring System** +- Earn points on level completion: based on time spent +- Consume points to unlock hints +- Points stored locally in StorageManager +- Synced from server on app start + +### 5. **Hint System** +- 3 hints per level +- Hint 1: always free +- Hints 2-3: point consumption +- Prevents double-unlock with flag + +### 6. **Timer Mechanic** +- 60-second countdown per level +- Displayed in clock label +- Affects points earned (faster = more points) +- Plays sound on timeout + +### 7. **Share Challenge Mode** +- Different game flow from normal mode +- Uses ShareManager instead of LevelDataManager +- Reports progress back to server +- Returns to home on completion + +### 8. **Toast Notification System** +- Singleton manager with prefab pool +- Supports custom duration +- Fade-out animation +- Auto-destroys after display + diff --git a/assets/prefabs/PageLevel.ts b/assets/prefabs/PageLevel.ts index 92e41e4..751edfd 100644 --- a/assets/prefabs/PageLevel.ts +++ b/assets/prefabs/PageLevel.ts @@ -2,13 +2,14 @@ import { _decorator, Node, EditBox, instantiate, Vec3, Button, Label, Sprite, Sp import { BaseView } from 'db://assets/scripts/core/BaseView'; import { ViewManager } from 'db://assets/scripts/core/ViewManager'; import { StorageManager } from 'db://assets/scripts/utils/StorageManager'; -import { UserAssetsManager } from 'db://assets/scripts/utils/UserAssetsManager'; +import { StaminaManager } from 'db://assets/scripts/utils/StaminaManager'; import { WxSDK } from 'db://assets/scripts/utils/WxSDK'; import { LevelDataManager } from 'db://assets/scripts/utils/LevelDataManager'; import { RuntimeLevelConfig } from 'db://assets/scripts/types/LevelTypes'; import { ToastManager } from 'db://assets/scripts/utils/ToastManager'; import { ShareManager } from 'db://assets/scripts/utils/ShareManager'; import { PassModal } from 'db://assets/prefabs/PassModal'; +import { StaminaInfo } from 'db://assets/scripts/types/ApiTypes'; const { ccclass, property } = _decorator; /** @@ -60,7 +61,7 @@ export class PageLevel extends BaseView { @property(Label) clockLabel: Label | null = null; - /** 积分显示标签(prefab 中序列化名为 liveLabel,保持兼容) */ + /** 体力值显示标签(prefab 中序列化名为 liveLabel,保持兼容) */ @property(Label) liveLabel: Label | null = null; @@ -99,7 +100,7 @@ export class PageLevel extends BaseView { /** 是否正在切换关卡(防止重复提交) */ private _isTransitioning: boolean = false; - /** 是否正在解锁提示(防止双击重复消耗积分) */ + /** 是否正在解锁提示(防止双击重复触发) */ private _isUnlocking: boolean = false; /** 通关弹窗实例 */ @@ -108,6 +109,9 @@ export class PageLevel extends BaseView { /** 是否处于分享挑战模式 */ private _isShareMode: boolean = false; + /** 体力恢复倒计时定时器 */ + private _staminaTimerId: ReturnType | null = null; + /** * 页面首次加载时调用 */ @@ -125,16 +129,14 @@ export class PageLevel extends BaseView { this.currentLevelIndex = StorageManager.getCurrentLevelIndex(); console.log(`[PageLevel] 恢复关卡进度: 第 ${this.currentLevelIndex + 1} 关`); } - this.updatePointsLabel(); + this.updateStaminaLabel(); this.initIconSetting(); this.initUnlockButtons(); this.initSubmitButton(); - // 异步加载关卡资源,完成后启动倒计时 - this.initLevel().then(() => { - this.startCountdown(); - }).catch(err => { - console.error('[PageLevel] 加载关卡失败:', err); + // 异步加载关卡资源并调用进入关卡接口,完成后启动倒计时 + this._enterAndInitLevel().catch(err => { + console.error('[PageLevel] 进入关卡失败:', err); }); } @@ -143,7 +145,8 @@ export class PageLevel extends BaseView { */ onViewShow(): void { console.log('[PageLevel] onViewShow'); - this.updatePointsLabel(); + this.updateStaminaLabel(); + this._startStaminaRecoverTimer(); } /** @@ -151,6 +154,7 @@ export class PageLevel extends BaseView { */ onViewHide(): void { console.log('[PageLevel] onViewHide'); + this._stopStaminaRecoverTimer(); } /** @@ -161,6 +165,7 @@ export class PageLevel extends BaseView { this.clearInputNodes(); this.stopCountdown(); this._closePassModal(); + this._stopStaminaRecoverTimer(); // 清理事件监听 this.iconSetting?.off(Node.EventType.TOUCH_END, this.onIconSettingClick, this); @@ -170,16 +175,18 @@ export class PageLevel extends BaseView { } /** - * 初始化关卡(从 API 数据加载,异步确保资源就绪) + * 进入关卡并初始化 + * 1. 加载关卡图片资源 + * 2. 调用进入关卡接口(消耗体力,获取答案和线索) + * 3. 启动倒计时 */ - private async initLevel(): Promise { + private async _enterAndInitLevel(): Promise { + // 先加载关卡图片资源 let config: RuntimeLevelConfig | null = null; if (this._isShareMode) { - // 分享模式:从 ShareManager 获取关卡 config = await ShareManager.instance.ensureShareLevelReady(this.currentLevelIndex); } else { - // 正常模式:先尝试缓存,再异步加载 config = LevelDataManager.instance.getLevelConfig(this.currentLevelIndex); if (!config) { console.log(`[PageLevel] 关卡 ${this.currentLevelIndex + 1} 资源未缓存,开始加载...`); @@ -192,8 +199,53 @@ export class PageLevel extends BaseView { return; } + // 非分享模式下,调用进入关卡接口获取答案和线索 + if (!this._isShareMode) { + const levelId = LevelDataManager.instance.getLevelId(this.currentLevelIndex); + if (levelId) { + const enterData = await StaminaManager.instance.enterLevel(levelId); + if (!enterData) { + // 进入关卡失败(可能是体力不足) + const stamina = StaminaManager.instance.getStamina(); + if (stamina.current <= 0) { + ToastManager.show('体力不足,请等待恢复'); + this._startStaminaRecoverTimer(); + } else { + ToastManager.show('进入关卡失败,请重试'); + } + this.updateStaminaLabel(); + return; + } + + // 提示用户消耗体力 + ToastManager.show(`消耗1点体力,剩余 ${enterData.stamina.current}/${enterData.stamina.max}`); + + // 用 enter 接口返回的数据更新关卡配置(填充答案和线索) + LevelDataManager.instance.updateLevelDetails( + this.currentLevelIndex, + { + answer: enterData.answer, + hint1: enterData.hint1, + hint2: enterData.hint2, + hint3: enterData.hint3, + } + ); + + // 重新获取更新后的配置 + config = LevelDataManager.instance.getLevelConfig(this.currentLevelIndex); + if (!config) { + console.error('[PageLevel] 更新关卡详情后获取配置失败'); + return; + } + + // 更新体力显示 + this.updateStaminaLabel(); + } + } + console.log(`[PageLevel] 初始化关卡 ${this.currentLevelIndex + 1}: ${config.name}`); this._applyLevelConfig(config); + this.startCountdown(); } /** @@ -212,8 +264,10 @@ export class PageLevel extends BaseView { // 设置主图 this.setMainImage(config.spriteFrame); - // 设置线索1(默认解锁) - this.setClue(1, config.clue1); + // 设置线索1(默认解锁,如果有的话) + if (config.clue1) { + this.setClue(1, config.clue1); + } // 隐藏线索2、3 this.hideClue(2); @@ -224,7 +278,9 @@ export class PageLevel extends BaseView { this.showUnlockButton(3); // 根据答案长度创建单个输入框 - this.createSingleInput(config.answer.length); + if (config.answer) { + this.createSingleInput(config.answer.length); + } // 更新倒计时显示 this.updateClockLabel(); @@ -239,7 +295,7 @@ export class PageLevel extends BaseView { LevelDataManager.instance.preloadNextLevel(this.currentLevelIndex); } - console.log(`[PageLevel] 初始化关卡 ${this.currentLevelIndex + 1}, 答案长度: ${config.answer.length}`); + console.log(`[PageLevel] 初始化关卡 ${this.currentLevelIndex + 1}, 答案长度: ${config.answer?.length ?? 0}`); } /** @@ -313,6 +369,11 @@ export class PageLevel extends BaseView { private clearInputNodes(): void { for (const node of this._inputNodes) { if (node.isValid) { + const editBox = node.getComponent(EditBox); + if (editBox) { + editBox.node.off(EditBox.EventType.TEXT_CHANGED, this.onInputTextChanged, this); + editBox.node.off(EditBox.EventType.EDITING_DID_ENDED, this.onInputEditingEnded, this); + } node.destroy(); } } @@ -501,38 +562,38 @@ export class PageLevel extends BaseView { } /** - * 点击解锁线索 + * 点击解锁线索(观看激励视频广告后解锁) */ private async onUnlockClue(index: number): Promise { - // 防止双击重复消耗 + // 防止双击重复触发 if (this._isUnlocking) return; - if (!this.hasPoints()) { - ToastManager.show('积分不足,无法解锁提示!'); - return; - } - this._isUnlocking = true; try { - const levelId = this._currentConfig?.id; - const success = await UserAssetsManager.instance.consumePoint(levelId, index); - if (!success) { - ToastManager.show('积分不足,无法解锁提示!'); + // 检查线索是否存在 + if (!this._currentConfig) return; + + const clueContent = index === 2 ? this._currentConfig.clue2 : this._currentConfig.clue3; + if (!clueContent) { + ToastManager.show('该提示暂未配置'); + return; + } + + // 调用微信激励视频广告 + ToastManager.show('观看视频即可解锁提示'); + const adWatched = await WxSDK.showRewardedVideoAd(); + if (!adWatched) { + ToastManager.show('需要看完视频才能解锁提示哦'); return; } - this.updatePointsLabel(); this.playClickSound(); this.hideUnlockButton(index); this.showClue(index); + this.setClue(index, clueContent); - if (this._currentConfig) { - const clueContent = index === 2 ? this._currentConfig.clue2 : this._currentConfig.clue3; - this.setClue(index, clueContent); - } - - console.log(`[PageLevel] 解锁线索${index}`); + console.log(`[PageLevel] 通过观看广告解锁线索${index}`); } finally { this._isUnlocking = false; } @@ -639,17 +700,70 @@ export class PageLevel extends BaseView { // 可以在这里添加游戏结束逻辑 } - // ========== 积分相关方法 ========== + // ========== 体力值相关方法 ========== - private updatePointsLabel(): void { + /** 上次显示的体力值,用于变更检测 */ + private _lastDisplayedStamina: number = -1; + + /** + * 更新体力值显示(仅值变化时更新 UI) + */ + private updateStaminaLabel(): void { if (this.liveLabel) { - const points = StorageManager.getPoints(); - this.liveLabel.string = `x ${points}`; + const stamina = StaminaManager.instance.getStamina(); + if (stamina.current !== this._lastDisplayedStamina) { + this.liveLabel.string = `x ${stamina.current}`; + this._lastDisplayedStamina = stamina.current; + } } } - private hasPoints(): boolean { - return StorageManager.hasPoints(); + /** + * 启动体力恢复倒计时 UI + */ + private _startStaminaRecoverTimer(): void { + this._stopStaminaRecoverTimer(); + + const stamina = StaminaManager.instance.getStamina(); + if (!stamina.nextRecoverAt || stamina.current >= stamina.max) { + return; + } + + const targetTime = new Date(stamina.nextRecoverAt).getTime(); + if (isNaN(targetTime)) return; + + this._staminaTimerId = setInterval(() => { + if (targetTime - Date.now() > 0) return; + + // 恢复一点体力 + const currentStamina = StaminaManager.instance.getStamina(); + const newCurrent = Math.min(currentStamina.current + 1, currentStamina.max); + const newStamina: StaminaInfo = { + ...currentStamina, + current: newCurrent, + nextRecoverAt: newCurrent < currentStamina.max + ? new Date(Date.now() + 10 * 60 * 1000).toISOString() + : null, + }; + StaminaManager.instance.updateStamina(newStamina); + this.updateStaminaLabel(); + + this._stopStaminaRecoverTimer(); + + if (newCurrent < currentStamina.max) { + this._startStaminaRecoverTimer(); + } + }, 1000); + } + + /** + * 停止体力恢复倒计时 + */ + private _stopStaminaRecoverTimer(): void { + if (this._staminaTimerId !== null) { + clearInterval(this._staminaTimerId); + this._staminaTimerId = null; + } } // ========== 答案提交与关卡切换 ========== @@ -674,7 +788,7 @@ export class PageLevel extends BaseView { } /** - * 显示成功提示 + * 显示成功提示并上报通关 */ private async showSuccess(): Promise { console.log('[PageLevel] 答案正确!'); @@ -692,8 +806,13 @@ export class PageLevel extends BaseView { const timeSpent = 60 - this._countdown; if (!this._isShareMode) { - await UserAssetsManager.instance.earnPoint(levelId, timeSpent); - this.updatePointsLabel(); + // 上报通关耗时 + const result = await StaminaManager.instance.completeLevel(levelId, timeSpent); + if (result) { + console.log(`[PageLevel] 通关上报成功,首次通关: ${result.firstClear}`); + } + // 标记关卡为已通关(本地缓存) + LevelDataManager.instance.markLevelCompleted(this.currentLevelIndex); } else { // fire-and-forget: errors are logged inside reportLevelProgress void ShareManager.instance.reportLevelProgress(levelId, true, timeSpent); @@ -812,11 +931,8 @@ export class PageLevel extends BaseView { return; } - // 重置并加载下一关,重新开始倒计时 - await this.initLevel(); - this.startCountdown(); + // 重置并加载下一关(包含进入关卡接口调用) + await this._enterAndInitLevel(); console.log(`[PageLevel] 进入关卡 ${this.currentLevelIndex + 1}`); } } - - diff --git a/assets/prefabs/PassModal.ts b/assets/prefabs/PassModal.ts index d9fc472..a3efd9a 100644 --- a/assets/prefabs/PassModal.ts +++ b/assets/prefabs/PassModal.ts @@ -30,7 +30,7 @@ export class PassModal extends BaseView { @property(Node) shareButton: Node | null = null; - /** 提示Label(如 +1 生命) */ + /** 提示Label(如 恭喜通关) */ @property(Label) tipLabel: Label | null = null; diff --git a/assets/scripts/config/ApiConfig.ts b/assets/scripts/config/ApiConfig.ts index f366806..5d9f9a2 100644 --- a/assets/scripts/config/ApiConfig.ts +++ b/assets/scripts/config/ApiConfig.ts @@ -8,27 +8,38 @@ export const API_BASE = 'https://ilookai.cn/api/v1'; /** API 端点 */ export const API_ENDPOINTS = { + /** 微信登录 */ WX_LOGIN: `${API_BASE}/auth/wx-login`, - USER_ASSETS: `${API_BASE}/user/assets`, - USER_ASSETS_CONSUME: `${API_BASE}/user/assets/consume`, - USER_ASSETS_EARN: `${API_BASE}/user/assets/earn`, + /** 用户资料(含实时体力) */ + USER_PROFILE: `${API_BASE}/user/profile`, + /** 游戏数据(体力 + 通关进度) */ USER_GAME_DATA: `${API_BASE}/user/game-data`, - LEVELS: `${API_BASE}/wechat-game/levels`, + /** 关卡列表 */ + LEVELS: `${API_BASE}/levels`, + /** 游戏配置 */ + GAME_CONFIGS: `${API_BASE}/game-configs`, + /** 分享相关 */ SHARE_CREATE: `${API_BASE}/share`, SHARE_PROGRESS: `${API_BASE}/share/progress`, + /** 用户信息 */ USER_INFO: `${API_BASE}/user/info`, } as const; -/** 构建加入分享的 URL */ +export function getLevelEnterUrl(levelId: string): string { + return `${API_BASE}/levels/${levelId}/enter`; +} + +export function getLevelCompleteUrl(levelId: string): string { + return `${API_BASE}/levels/${levelId}/complete`; +} + export function getShareJoinUrl(code: string): string { return `${API_BASE}/share/${code}/join`; } -/** 积分操作原因 */ -export const POINT_REASONS = { - HINT_UNLOCK: 'hint_unlock', - LEVEL_COMPLETE: 'level_complete', -} as const; +export function getGameConfigUrl(key: string): string { + return `${API_BASE}/game-configs/${key}`; +} /** 请求超时时间(毫秒) */ export const API_TIMEOUT = { diff --git a/assets/scripts/types/ApiTypes.ts b/assets/scripts/types/ApiTypes.ts index ada35a5..f1cedb9 100644 --- a/assets/scripts/types/ApiTypes.ts +++ b/assets/scripts/types/ApiTypes.ts @@ -7,6 +7,17 @@ export interface ApiEnvelope { success: boolean; data: T | null; message: string | null; + timestamp: string; +} + +/** 体力值信息 */ +export interface StaminaInfo { + /** 当前体力值(已计算恢复) */ + current: number; + /** 体力上限,固定为 5 */ + max: number; + /** 下一点体力恢复的时间(ISO 8601),满体力时为 null */ + nextRecoverAt: string | null; } /** 登录响应数据 */ @@ -15,21 +26,64 @@ export interface WxLoginData { user: { id: string; nickname: string | null; - points: number; + stamina: number; }; } -/** 积分响应数据 */ -export interface UserAssetsData { - points: number; +/** 用户资料响应数据 */ +export interface UserProfileData { + id: string; + nickname: string | null; + stamina: StaminaInfo; } /** 游戏数据响应(Loading 页面) */ export interface GameData { - user: { id: string; points: number }; + user: { + id: string; + stamina: StaminaInfo; + }; completedLevelIds: string[]; } +/** 关卡列表项 */ +export interface LevelListItem { + id: string; + level: number; + imageUrl: string; + answer: string | null; + hint1: string | null; + hint2: string | null; + hint3: string | null; + completed: boolean; + timeSpent: number | null; +} + +/** 关卡列表响应 */ +export interface LevelListData { + levels: LevelListItem[]; + total: number; +} + +/** 进入关卡响应 */ +export interface EnterLevelData { + id: string; + level: number; + imageUrl: string; + answer: string; + hint1: string | null; + hint2: string | null; + hint3: string | null; + stamina: StaminaInfo; +} + +/** 通关上报响应 */ +export interface CompleteLevelData { + firstClear: boolean; + levelId: string; + timeSpent: number; +} + /** 创建分享响应 */ export interface CreateShareData { shareCode: string; diff --git a/assets/scripts/types/LevelTypes.ts b/assets/scripts/types/LevelTypes.ts index f3eebad..f30c49e 100644 --- a/assets/scripts/types/LevelTypes.ts +++ b/assets/scripts/types/LevelTypes.ts @@ -1,7 +1,7 @@ import { SpriteFrame } from 'cc'; /** - * API 返回的单个关卡数据结构 + * API 返回的单个关卡数据结构(关卡列表) */ export interface ApiLevelData { /** 关卡 ID (UUID) */ @@ -10,20 +10,22 @@ export interface ApiLevelData { level: number; /** 主图 URL */ imageUrl: string; - /** 线索1 */ - hint1: string; - /** 线索2 */ - hint2: string; - /** 线索3 */ - hint3: string; - /** 答案 */ - answer: string; - /** 排序 */ - sortOrder: number; + /** 线索1(未通关时为 null) */ + hint1: string | null; + /** 线索2(未通关时为 null) */ + hint2: string | null; + /** 线索3(未通关时为 null) */ + hint3: string | null; + /** 答案(未通关时为 null) */ + answer: string | null; + /** 是否已通关 */ + completed: boolean; + /** 通关时长(秒),未通关时为 null */ + timeSpent: number | null; } /** - * API 响应结构 + * API 响应结构(关卡列表) */ export interface ApiResponse { /** 是否成功 */ @@ -47,12 +49,14 @@ export interface RuntimeLevelConfig { name: string; /** 主图 SpriteFrame(可能为 null 如果加载失败) */ spriteFrame: SpriteFrame | null; - /** 线索1 */ - clue1: string; - /** 线索2 */ - clue2: string; - /** 线索3 */ - clue3: string; - /** 答案 */ - answer: string; + /** 线索1(未通关时为 null,进入关卡后由 enter 接口获取) */ + clue1: string | null; + /** 线索2(未通关时为 null) */ + clue2: string | null; + /** 线索3(未通关时为 null) */ + clue3: string | null; + /** 答案(未通关时为 null,进入关卡后由 enter 接口获取) */ + answer: string | null; + /** 是否已通关 */ + completed: boolean; } diff --git a/assets/scripts/utils/AuthManager.ts b/assets/scripts/utils/AuthManager.ts index a7a0f3b..c1f379a 100644 --- a/assets/scripts/utils/AuthManager.ts +++ b/assets/scripts/utils/AuthManager.ts @@ -15,8 +15,6 @@ export class AuthManager { private _isLoggedIn: boolean = false; /** 服务端返回的已完成关卡 ID(登录后暂存,等 LevelDataManager 就绪后同步) */ private _completedLevelIds: string[] = []; - /** 服务端返回的已完成关卡 ID(登录后暂存,等 LevelDataManager 就绪后同步) */ - private _completedLevelIds: string[] = []; static get instance(): AuthManager { if (!this._instance) { @@ -88,12 +86,14 @@ export class AuthManager { this._userId = user.id; this._isLoggedIn = true; - StorageManager.setPoints(user.points); - // 获取通关进度 - await this.fetchCompletedLevels(); + // 登录响应中 stamina 是原始数值(不含实时恢复),先存储默认体力 + // 后续通过 game-data 接口获取完整 StaminaInfo + console.log(`[AuthManager] 登录成功,用户: ${user.id},体力: ${user.stamina}`); + + // 获取通关进度和完整体力信息 + await this.fetchGameData(); - console.log(`[AuthManager] 登录成功,用户: ${user.id},积分: ${user.points}`); return true; } catch (err) { console.error('[AuthManager] 登录异常:', err); @@ -102,45 +102,42 @@ export class AuthManager { } private async validateToken(): Promise { - try { - const response = await HttpUtil.get>( - API_ENDPOINTS.USER_GAME_DATA, - API_TIMEOUT.SHORT - ); + const gameData = await this._fetchGameData(); + if (!gameData) return false; - if (!response.success || !response.data) { - return false; - } + this._userId = gameData.user.id; + this._isLoggedIn = true; + StorageManager.setStamina(gameData.user.stamina); + this._completedLevelIds = gameData.completedLevelIds; - this._userId = response.data.user.id; - this._isLoggedIn = true; - StorageManager.setPoints(response.data.user.points); - this._completedLevelIds = response.data.completedLevelIds; + console.log(`[AuthManager] Token 验证成功,体力: ${gameData.user.stamina.current}/${gameData.user.stamina.max},已完成: ${this._completedLevelIds.length} 关`); + return true; + } - console.log(`[AuthManager] Token 验证成功,积分: ${response.data.user.points},已完成: ${this._completedLevelIds.length} 关`); - return true; - } catch { - return false; + /** + * 登录成功后获取游戏数据(体力 + 通关进度) + */ + private async fetchGameData(): Promise { + const gameData = await this._fetchGameData(); + if (gameData) { + this._completedLevelIds = gameData.completedLevelIds; + StorageManager.setStamina(gameData.user.stamina); } } /** - * 登录成功后获取通关进度 + * 从服务端获取游戏数据(共用方法) */ - private async fetchCompletedLevels(): Promise { + private async _fetchGameData(): Promise { try { const response = await HttpUtil.get>( API_ENDPOINTS.USER_GAME_DATA, API_TIMEOUT.SHORT ); - - if (response.success && response.data) { - this._completedLevelIds = response.data.completedLevelIds; - // 同步最新积分 - StorageManager.setPoints(response.data.user.points); - } + return (response.success && response.data) ? response.data : null; } catch { - console.warn('[AuthManager] 获取通关进度失败'); + console.warn('[AuthManager] 获取游戏数据失败'); + return null; } } } diff --git a/assets/scripts/utils/LevelDataManager.ts b/assets/scripts/utils/LevelDataManager.ts index 55797d4..b0f71e6 100644 --- a/assets/scripts/utils/LevelDataManager.ts +++ b/assets/scripts/utils/LevelDataManager.ts @@ -109,6 +109,45 @@ export class LevelDataManager { return this._apiData.length; } + /** + * 获取指定索引的关卡 ID + * @param index 关卡索引 + */ + getLevelId(index: number): string | null { + if (index < 0 || index >= this._apiData.length) { + return null; + } + return this._apiData[index].id; + } + + /** + * 检查指定索引的关卡是否已通关 + * @param index 关卡索引 + */ + isLevelCompleted(index: number): boolean { + if (index < 0 || index >= this._apiData.length) { + return false; + } + return this._apiData[index].completed; + } + + /** + * 标记指定关卡为已通关(本地缓存更新) + * @param index 关卡索引 + */ + markLevelCompleted(index: number): void { + if (index < 0 || index >= this._apiData.length) { + return; + } + this._apiData[index].completed = true; + + // 同时更新运行时配置的 completed 状态 + const config = this._levelConfigs.get(index); + if (config) { + this._levelConfigs.set(index, { ...config, completed: true }); + } + } + /** * 根据已完成的关卡 ID 列表,计算最高已完成关卡索引 * @param completedLevelIds 服务端返回的已完成关卡 ID @@ -191,6 +230,27 @@ export class LevelDataManager { return config; } + /** + * 用 enter 接口返回的数据更新运行时关卡配置(填充答案和线索) + */ + updateLevelDetails(index: number, details: { answer: string; hint1: string | null; hint2: string | null; hint3: string | null }): void { + const config = this._levelConfigs.get(index); + if (!config) { + console.warn(`[LevelDataManager] 关卡 ${index} 配置不存在,无法更新详情`); + return; + } + + this._levelConfigs.set(index, { + ...config, + answer: details.answer, + clue1: details.hint1 ?? null, + clue2: details.hint2 ?? null, + clue3: details.hint3 ?? null, + }); + + console.log(`[LevelDataManager] 关卡 ${index} 详情已更新`); + } + /** * 预加载下一关图片(静默加载,不阻塞) * 在进入当前关卡后调用,提前加载下一关资源 @@ -278,7 +338,8 @@ export class LevelDataManager { clue1: data.hint1, clue2: data.hint2, clue3: data.hint3, - answer: data.answer + answer: data.answer, + completed: data.completed, }; } diff --git a/assets/scripts/utils/StaminaManager.ts b/assets/scripts/utils/StaminaManager.ts new file mode 100644 index 0000000..03e26d2 --- /dev/null +++ b/assets/scripts/utils/StaminaManager.ts @@ -0,0 +1,110 @@ +import { HttpUtil } from './HttpUtil'; +import { StorageManager } from './StorageManager'; +import { AuthManager } from './AuthManager'; +import { API_TIMEOUT, getLevelEnterUrl, getLevelCompleteUrl } from '../config/ApiConfig'; +import { ApiEnvelope, StaminaInfo, EnterLevelData, CompleteLevelData } from '../types/ApiTypes'; + +/** + * 体力值管理器 + * 单例模式,负责体力值的服务端同步、进入关卡和通关上报 + * 以服务端为准,本地 StorageManager 作为缓存 + */ +export class StaminaManager { + private static _instance: StaminaManager | null = null; + + static get instance(): StaminaManager { + if (!this._instance) { + this._instance = new StaminaManager(); + } + return this._instance; + } + + private constructor() {} + + /** + * 获取当前体力信息(从本地缓存) + */ + getStamina(): StaminaInfo { + return StorageManager.getStamina(); + } + + /** + * 更新本地缓存的体力信息 + * @param stamina 服务端返回的体力信息 + */ + updateStamina(stamina: StaminaInfo): void { + StorageManager.setStamina(stamina); + } + + /** + * 检查当前是否有足够的体力 + */ + hasStamina(): boolean { + return StorageManager.hasStamina(); + } + + /** + * 进入关卡 + * 消耗 1 点体力(未通关关卡),获取完整关卡详情(含答案和线索) + * @param levelId 关卡 ID + * @returns 关卡详情,失败时返回 null + */ + async enterLevel(levelId: string): Promise { + if (!AuthManager.instance.isLoggedIn) { + console.warn('[StaminaManager] 未登录,无法进入关卡'); + return null; + } + + try { + const response = await HttpUtil.post>( + getLevelEnterUrl(levelId), + {}, + API_TIMEOUT.DEFAULT + ); + + if (response.success && response.data) { + StorageManager.setStamina(response.data.stamina); + console.log(`[StaminaManager] 进入关卡 ${levelId},体力: ${response.data.stamina.current}/${response.data.stamina.max}`); + return response.data; + } + + console.warn('[StaminaManager] 进入关卡失败:', response.message); + return null; + } catch (err) { + console.error('[StaminaManager] 进入关卡请求失败:', err); + return null; + } + } + + /** + * 通关上报 + * @param levelId 关卡 ID + * @param timeSpent 通关耗时(秒) + * @returns 通关响应,失败时返回 null + */ + async completeLevel(levelId: string, timeSpent: number): Promise { + if (!AuthManager.instance.isLoggedIn) { + console.warn('[StaminaManager] 未登录,无法上报通关'); + return null; + } + + try { + const response = await HttpUtil.post>( + getLevelCompleteUrl(levelId), + { timeSpent }, + API_TIMEOUT.DEFAULT + ); + + if (response.success && response.data) { + console.log(`[StaminaManager] 通关上报成功: ${levelId}, 首次: ${response.data.firstClear}, 耗时: ${response.data.timeSpent}s`); + return response.data; + } + + console.warn('[StaminaManager] 通关上报失败:', response.message); + return null; + } catch (err) { + console.error('[StaminaManager] 通关上报请求失败:', err); + return null; + } + } +} diff --git a/assets/scripts/utils/UserAssetsManager.ts.meta b/assets/scripts/utils/StaminaManager.ts.meta similarity index 70% rename from assets/scripts/utils/UserAssetsManager.ts.meta rename to assets/scripts/utils/StaminaManager.ts.meta index 6c48010..8867e1c 100644 --- a/assets/scripts/utils/UserAssetsManager.ts.meta +++ b/assets/scripts/utils/StaminaManager.ts.meta @@ -2,7 +2,7 @@ "ver": "4.0.24", "importer": "typescript", "imported": true, - "uuid": "ddba71db-75ed-468d-ac99-b4632c0b2ae4", + "uuid": "7fb84423-af68-468c-a17b-7bdd195eb815", "files": [], "subMetas": {}, "userData": {} diff --git a/assets/scripts/utils/StorageManager.ts b/assets/scripts/utils/StorageManager.ts index c3ffe06..6aa4f52 100644 --- a/assets/scripts/utils/StorageManager.ts +++ b/assets/scripts/utils/StorageManager.ts @@ -1,4 +1,5 @@ import { sys } from 'cc'; +import { StaminaInfo } from '../types/ApiTypes'; /** * 用户进度数据结构 @@ -10,13 +11,21 @@ interface UserProgress { maxUnlockedLevelIndex: number; } +/** + * 用户信息结构 + */ +interface UserInfo { + avatarUrl: string; + nickName: string; +} + /** * 本地存储管理器 * 统一管理用户数据的本地持久化存储 */ export class StorageManager { - /** 积分存储键 */ - private static readonly KEY_POINTS = 'game_points'; + /** 体力值存储键 */ + private static readonly KEY_STAMINA = 'game_stamina'; /** 用户进度存储键 */ private static readonly KEY_PROGRESS = 'game_progress'; @@ -27,11 +36,12 @@ export class StorageManager { /** 用户信息存储键 */ private static readonly KEY_USER_INFO = 'user_info'; - /** 默认积分 */ - private static readonly DEFAULT_POINTS = 10; - - /** 最小积分 */ - private static readonly MIN_POINTS = 0; + /** 默认体力值 */ + private static readonly DEFAULT_STAMINA: StaminaInfo = { + current: 5, + max: 5, + nextRecoverAt: null, + }; /** 默认进度 */ private static readonly DEFAULT_PROGRESS: UserProgress = { @@ -42,75 +52,52 @@ export class StorageManager { /** 进度缓存(避免重复读取 localStorage) */ private static _progressCache: UserProgress | null = null; - // ==================== 积分管理 ==================== + /** 体力缓存(避免重复 JSON 解析) */ + private static _staminaCache: StaminaInfo | null = null; + + // ==================== 体力值管理 ==================== /** - * 获取当前积分 - * @returns 当前积分,新用户返回默认值 10 + * 获取当前体力信息(带内存缓存,避免重复 JSON 解析) */ - static getPoints(): number { - const stored = sys.localStorage.getItem(StorageManager.KEY_POINTS); + static getStamina(): StaminaInfo { + if (StorageManager._staminaCache) { + return { ...StorageManager._staminaCache }; + } + + const stored = sys.localStorage.getItem(StorageManager.KEY_STAMINA); if (stored === null || stored === '') { - // 新用户,设置默认值 - StorageManager.setPoints(StorageManager.DEFAULT_POINTS); - return StorageManager.DEFAULT_POINTS; + StorageManager.setStamina(StorageManager.DEFAULT_STAMINA); + return { ...StorageManager.DEFAULT_STAMINA }; } - const points = parseInt(stored, 10); - // 防止异常数据 - if (isNaN(points) || points < 0) { - StorageManager.setPoints(StorageManager.DEFAULT_POINTS); - return StorageManager.DEFAULT_POINTS; + try { + const stamina = JSON.parse(stored) as StaminaInfo; + if (typeof stamina.current !== 'number' || typeof stamina.max !== 'number') { + StorageManager.setStamina(StorageManager.DEFAULT_STAMINA); + return { ...StorageManager.DEFAULT_STAMINA }; + } + StorageManager._staminaCache = stamina; + return { ...stamina }; + } catch { + StorageManager.setStamina(StorageManager.DEFAULT_STAMINA); + return { ...StorageManager.DEFAULT_STAMINA }; } - return points; } /** - * 设置积分 - * @param points 积分 + * 设置体力信息(同时更新缓存) */ - static setPoints(points: number): void { - const validPoints = Math.max(StorageManager.MIN_POINTS, points); - sys.localStorage.setItem(StorageManager.KEY_POINTS, validPoints.toString()); - console.log(`[StorageManager] 积分已更新: ${validPoints}`); + static setStamina(stamina: StaminaInfo): void { + StorageManager._staminaCache = stamina; + sys.localStorage.setItem(StorageManager.KEY_STAMINA, JSON.stringify(stamina)); + console.log(`[StorageManager] 体力已更新: ${stamina.current}/${stamina.max}`); } /** - * 消耗一个积分 - * @returns 是否消耗成功(积分不足时返回 false) + * 检查是否有足够的体力 */ - static consumePoint(): boolean { - const currentPoints = StorageManager.getPoints(); - if (currentPoints <= 0) { - console.warn('[StorageManager] 积分不足,无法消耗'); - return false; - } - StorageManager.setPoints(currentPoints - 1); - return true; - } - - /** - * 增加一个积分 - */ - static addPoint(): void { - const currentPoints = StorageManager.getPoints(); - StorageManager.setPoints(currentPoints + 1); - console.log(`[StorageManager] 获得一个积分,当前积分: ${currentPoints + 1}`); - } - - /** - * 检查是否有足够的积分 - * @returns 是否有积分 - */ - static hasPoints(): boolean { - return StorageManager.getPoints() > 0; - } - - /** - * 重置积分为默认值 - */ - static resetPoints(): void { - StorageManager.setPoints(StorageManager.DEFAULT_POINTS); - console.log('[StorageManager] 积分已重置为默认值'); + static hasStamina(): boolean { + return StorageManager.getStamina().current > 0; } // ==================== 认证 Token 管理 ==================== @@ -261,10 +248,10 @@ export class StorageManager { } /** - * 重置所有数据(积分 + 进度) + * 重置所有数据(体力 + 进度) */ static resetAll(): void { - StorageManager.resetPoints(); + StorageManager.setStamina(StorageManager.DEFAULT_STAMINA); StorageManager.resetProgress(); StorageManager.clearToken(); StorageManager.clearUserInfo(); @@ -273,14 +260,6 @@ export class StorageManager { // ==================== 用户信息管理 ==================== - /** - * 用户信息结构 - */ - interface UserInfo { - avatarUrl: string; - nickName: string; - } - /** * 保存用户信息(头像、昵称) * @param userInfo 用户信息对象 diff --git a/assets/scripts/utils/UserAssetsManager.ts b/assets/scripts/utils/UserAssetsManager.ts deleted file mode 100644 index 3010ac2..0000000 --- a/assets/scripts/utils/UserAssetsManager.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { HttpUtil } from './HttpUtil'; -import { StorageManager } from './StorageManager'; -import { AuthManager } from './AuthManager'; -import { API_ENDPOINTS, API_TIMEOUT, POINT_REASONS } from '../config/ApiConfig'; -import { ApiEnvelope, UserAssetsData } from '../types/ApiTypes'; - -/** - * 用户资产管理器 - * 单例模式,负责积分的服务端同步 - * 以服务端为准,本地 StorageManager 作为缓存 - */ -export class UserAssetsManager { - private static _instance: UserAssetsManager | null = null; - - static get instance(): UserAssetsManager { - if (!this._instance) { - this._instance = new UserAssetsManager(); - } - return this._instance; - } - - private constructor() {} - - /** - * 从服务端获取最新积分 - */ - async fetchPoints(): Promise { - if (!AuthManager.instance.isLoggedIn) { - return StorageManager.getPoints(); - } - - try { - const response = await HttpUtil.get>( - API_ENDPOINTS.USER_ASSETS, - API_TIMEOUT.SHORT - ); - - if (response.success && response.data) { - StorageManager.setPoints(response.data.points); - return response.data.points; - } - } catch (err) { - console.error('[UserAssetsManager] 获取积分失败:', err); - } - - return StorageManager.getPoints(); - } - - /** - * 消耗积分(解锁提示) - * @returns 是否消耗成功 - */ - async consumePoint(levelId?: string, hintIndex?: number): Promise { - if (!StorageManager.hasPoints()) { - return false; - } - - if (!AuthManager.instance.isLoggedIn) { - return StorageManager.consumePoint(); - } - - try { - const response = await HttpUtil.post>( - API_ENDPOINTS.USER_ASSETS_CONSUME, - { - reason: POINT_REASONS.HINT_UNLOCK, - levelId, - hintIndex, - }, - API_TIMEOUT.SHORT - ); - - if (response.success && response.data) { - StorageManager.setPoints(response.data.points); - return true; - } else { - console.warn('[UserAssetsManager] 消耗积分失败:', response.message); - return false; - } - } catch (err) { - console.error('[UserAssetsManager] 消耗积分请求失败,降级本地处理:', err); - return StorageManager.consumePoint(); - } - } - - /** - * 获得积分(通关奖励) - * @param levelId 关卡ID - * @param timeSpent 通关耗时(秒) - * @returns 获得后的积分数 - */ - async earnPoint(levelId: string, timeSpent: number): Promise { - if (!AuthManager.instance.isLoggedIn) { - StorageManager.addPoint(); - return StorageManager.getPoints(); - } - - try { - const response = await HttpUtil.post>( - API_ENDPOINTS.USER_ASSETS_EARN, - { - reason: POINT_REASONS.LEVEL_COMPLETE, - levelId, - timeSpent, - }, - API_TIMEOUT.SHORT - ); - - if (response.success && response.data) { - StorageManager.setPoints(response.data.points); - return response.data.points; - } - } catch (err) { - console.error('[UserAssetsManager] 获得积分请求失败,降级本地处理:', err); - } - - StorageManager.addPoint(); - return StorageManager.getPoints(); - } -} diff --git a/assets/scripts/utils/WxSDK.ts b/assets/scripts/utils/WxSDK.ts index 3a28913..845c3c0 100644 --- a/assets/scripts/utils/WxSDK.ts +++ b/assets/scripts/utils/WxSDK.ts @@ -39,7 +39,7 @@ export class WxSDK { /** * 获取 wx 全局对象(仅微信环境下可用) */ - private static getWx(): any { + static getWx(): any { if (!WxSDK.isWechat()) return null; return typeof wx !== 'undefined' ? wx : null; } @@ -217,6 +217,84 @@ export class WxSDK { }); } + // ==================== 激励视频广告 ==================== + + /** 激励视频广告实例(复用) */ + private static _rewardedVideoAd: any = null; + + /** + * 展示激励视频广告 + * 用户看完广告后返回 true,中途退出或失败返回 false + * 非微信环境直接返回 true(开发模式直接通过) + * @param adUnitId 广告单元 ID(默认使用项目配置的 ID) + * @returns Promise 是否看完广告 + */ + static showRewardedVideoAd(adUnitId: string = ''): Promise { + return new Promise((resolve) => { + const wxApi = WxSDK.getWx(); + if (!wxApi) { + console.log('[WxSDK] 非微信环境,跳过激励视频广告'); + resolve(true); + return; + } + + if (typeof wxApi.createRewardedVideoAd !== 'function') { + console.warn('[WxSDK] 当前微信版本不支持激励视频广告'); + resolve(true); + return; + } + + try { + // 复用或创建广告实例 + if (!WxSDK._rewardedVideoAd) { + WxSDK._rewardedVideoAd = wxApi.createRewardedVideoAd({ + adUnitId: adUnitId, + }); + } + + const ad = WxSDK._rewardedVideoAd; + + // 定义关闭回调(一次性) + const onClose = (res: any) => { + ad.offClose(onClose); + if (res && res.isEnded) { + console.log('[WxSDK] 激励视频广告观看完成'); + resolve(true); + } else { + console.log('[WxSDK] 激励视频广告中途退出'); + resolve(false); + } + }; + + // 定义错误回调(一次性) + const onError = (err: any) => { + ad.offError(onError); + ad.offClose(onClose); + console.error('[WxSDK] 激励视频广告错误:', err); + resolve(false); + }; + + ad.onClose(onClose); + ad.onError(onError); + + // 先尝试 show,如果广告未加载则先 load + ad.show().catch(() => { + ad.load().then(() => ad.show()).catch((loadErr: any) => { + ad.offClose(onClose); + ad.offError(onError); + console.error('[WxSDK] 激励视频广告加载失败:', loadErr); + resolve(false); + }); + }); + } catch (err) { + console.error('[WxSDK] 激励视频广告异常:', err); + resolve(false); + } + }); + } + + // ==================== 启动参数 ==================== + /** * 从启动参数中获取分享码 * @returns 分享码,不存在则返回 null