Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion assets/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,10 @@
"base_score": "Base Score",
"total_meme": "Total Meme Points",
"ok": "OK"
}
},
"rule": "You may play freely until you finish one game. Only the first completed game counts for daily reward.",
"download_all": "Download All",
"all_downloaded": "All downloaded",
"daily_locked": "Already completed today. Come back tomorrow!"
}
}
6 changes: 5 additions & 1 deletion assets/lang/jp.json
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,10 @@
"base_score": "基本スコア",
"total_meme": "総ミームポイント",
"ok": "確認"
}
},
"rule": "1日に自由にリトライできますが、報酬は最初のクリアのみです。完了後はその日は遊べません。",
"download_all": "すべてダウンロード",
"all_downloaded": "すべてダウンロード済み",
"daily_locked": "本日は完了しました。明日また来てください!"
}
}
6 changes: 5 additions & 1 deletion assets/lang/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,10 @@
"base_score": "기본 점수",
"total_meme": "총 밈 점수",
"ok": "확인"
}
},
"rule": "하루에 자유롭게 리트라이 가능하지만 보상은 첫 클리어만 지급됩니다. 완료 후 그날은 더 이상 플레이 불가합니다.",
"download_all": "전체 다운로드",
"all_downloaded": "모두 다운로드됨",
"daily_locked": "오늘은 이미 완료했습니다. 내일 다시 오세요!"
}
}
6 changes: 5 additions & 1 deletion assets/lang/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,10 @@
"base_score": "基礎分數",
"total_meme": "總迷因點數",
"ok": "確認"
}
},
"rule": "每日可自由重試,但僅首次完成遊戲可獲得獎勵。完成後當日不可再玩。",
"download_all": "全部下載",
"all_downloaded": "已全部下載",
"daily_locked": "今日已完成,請明日再來!"
}
}
110 changes: 110 additions & 0 deletions docs/step23/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# 📄 Step 23 規格書(卡拉 OK 次數限制與附加功能)

---

## 1. 階段目標
- **每日次數限制**:每日僅能「完成並結算」一次卡拉 OK。
- 遊玩中或中途退出不計次數。
- 若當日已結算過,則「歌曲選單」中所有歌曲項目均禁用(灰階/不可選)。
- **重試規則**:在完成結算前,玩家可無限次重新開始同一首或不同首,但僅能獲得一次結算獎勵。
- **UI 說明文字**:在音樂遊戲頁面上方新增一行描述規則的文案。
- **全曲下載按鈕**:
- 顯示於音樂遊戲頁面。
- 功能:一次下載所有 collect.json 中列出的音源檔。
- 若所有曲目皆已下載 → 按鈕 disable 並呈灰色。
- **試聽功能**:
- 當歌曲在選單中滾動至可見並顯示資訊時,自動播放該曲前 10 秒(loop 禁止)。
- 若該曲尚未下載 → 不播放。

---

## 2. 功能需求

### 2.1 次數限制邏輯
- 狀態存於 `game_state.karaoke`:
```json
{
"lastPlayDate": "2025-09-06",
"playedToday": true
}
````

* 判斷流程:

* 進入選單 → 若 `playedToday=true` 且 `lastPlayDate==today` → 禁用所有曲目。
* 若跨日 → 自動 reset:`playedToday=false`。
* 結算時(Step 22-4 完成獎勵入帳後)→ 將 `playedToday=true` 並更新 `lastPlayDate`。

* 首頁提醒規則:

* 若當日 `playedToday=true`,首頁「音樂遊戲」圖示右上角顯示紅色脈動通知(特效沿用設定按鈕上的紅點 ScaleTransition 動畫)。
* 隔日自動重置或 `playedToday=false` 時,該紅點自動消失。

### 2.2 UI 說明文字

* 置頂顯示一段文案(多語系):

* EN: `"You may play freely until you finish one game. Only the first completed game counts for daily reward."`
* ZH: `"每日可自由重試,但僅首次完成遊戲可獲得獎勵。完成後當日不可再玩。"`
* JP: `"1日に自由にリトライできますが、報酬は最初のクリアのみです。完了後はその日は遊べません。"`
* KO: `"하루에 자유롭게 리트라이 가능하지만 보상은 첫 클리어만 지급됩니다. 완료 후 그날은 더 이상 플레이 불가합니다."`

### 2.3 全曲下載按鈕

* 按鈕狀態:

* **Enable**:尚有未下載曲目 → 點擊後逐曲下載(顯示進度條,支援中途取消/重試)。
* **Disable**:所有曲目已下載完成 → 按鈕變灰不可點。

### 2.4 試聽功能

* 在歌曲列表元件中:

* 當滾動至某歌曲卡片進入可見區域 → 若已下載音源 → 優先自動播放前 10 秒並停止。
* 若裝置或格式限制無法可靠裁切 10 秒,則播放整首作為替代方案(允許在 UI 停止或切換時停止)。
* 若未下載 → 不播放(顯示「未下載」icon)。
* 每次僅允許一首曲子試聽,切換或開始滾動時需停止上一首。

---

## 3. 驗收標準

* ✅ **多次重試**:當日未完成結算時,可無限次開始遊戲;結算前關閉或重開遊戲不扣次。
* ✅ **結算後限制**:完成一場遊戲(顯示結算並入帳獎勵)後,歌曲選單中曲目全部鎖定不可再選,直到隔日解鎖。
* ✅ **UI 說明**:進入卡拉 OK 頁面時,頂部文字正確顯示(依語言切換)。
* ✅ **全曲下載**:點擊後逐首下載,完成後按鈕變灰。若所有音源皆已下載 → 預設 disable。
* ✅ **試聽功能**:滾動至某歌曲資訊 → 若音源已下載 → 自動播放前 10 秒並停止;若未下載 → 無聲播放且顯示提示。

---

## 4. 實例化需求測試案例

### 案例 1:當日未完成結算

* **Given** 今日尚未玩卡拉 OK
* **When** 連續開始並退出 3 次
* **Then** 可第 4 次繼續遊玩,未受限

### 案例 2:當日完成結算

* **Given** 今日首次完成遊戲並結算
* **When** 返回歌曲選單
* **Then** 所有曲目卡片呈灰階,禁止點擊

### 案例 3:跨日自動重置

* **Given** 昨日 `playedToday=true`
* **When** 系統日期變更至今日
* **Then** 自動 reset → `playedToday=false`;曲目可再選

### 案例 4:全曲下載按鈕

* **Given** collect.json 有 3 首歌,其中 1 首未下載
* **When** 點擊全曲下載 → 完成下載所有歌曲
* **Then** 按鈕變灰,顯示「已全部下載」

### 案例 5:試聽功能

* **Given** 滾動至「Echoes Of The Void」卡片
* **When** 該曲已下載 → 自動播放前 10 秒並停止
* **When** 該曲未下載 → 無聲播放且顯示「未下載」icon
61 changes: 59 additions & 2 deletions lib/models/game_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,52 @@ class GachaState {
int get hashCode => lastDate.hashCode ^ tenPackAdRemaining.hashCode;
}

/// Step 23: 卡拉 OK 每日限制狀態
class KaraokeState {
final String lastPlayDate; // YYYY-MM-DD in Asia/Taipei
final bool playedToday; // 是否已於當日完成並結算過

const KaraokeState({
required this.lastPlayDate,
required this.playedToday,
});

factory KaraokeState.initial() => const KaraokeState(
lastPlayDate: '',
playedToday: false,
);

factory KaraokeState.fromMap(Map<String, dynamic> map) {
return KaraokeState(
lastPlayDate: (map['lastPlayDate'] ?? '') as String,
playedToday: (map['playedToday'] ?? false) as bool,
);
}

Map<String, dynamic> toMap() => {
'lastPlayDate': lastPlayDate,
'playedToday': playedToday,
};

KaraokeState copyWith({String? lastPlayDate, bool? playedToday}) {
return KaraokeState(
lastPlayDate: lastPlayDate ?? this.lastPlayDate,
playedToday: playedToday ?? this.playedToday,
);
}

@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is KaraokeState &&
other.lastPlayDate == lastPlayDate &&
other.playedToday == playedToday;
}

@override
int get hashCode => lastPlayDate.hashCode ^ playedToday.hashCode;
}

class PetTicketQuest {
final double k;
final double target;
Expand Down Expand Up @@ -1091,6 +1137,7 @@ class GameState {
final TitlesState? titles;
final PendingGachaBatch? pendingGachaBatch;
final CheckinState? checkin;
final KaraokeState? karaoke; // Step23: 每日卡拉OK次數限制

const GameState({
required this.saveVersion,
Expand All @@ -1109,6 +1156,7 @@ class GameState {
this.titles,
this.pendingGachaBatch,
this.checkin,
this.karaoke,
});

/// 建立初始狀態
Expand All @@ -1127,6 +1175,7 @@ class GameState {
gachaHistory: const [],
gacha: null,
titles: const TitlesState(),
karaoke: null,
);
}

Expand Down Expand Up @@ -1203,6 +1252,9 @@ class GameState {
map.containsKey('checkin') && map['checkin'] is Map<String, dynamic>
? CheckinState.fromMap(map['checkin'] as Map<String, dynamic>)
: null,
karaoke: map.containsKey('karaoke') && map['karaoke'] is Map<String, dynamic>
? KaraokeState.fromMap(map['karaoke'] as Map<String, dynamic>)
: null,
);
}

Expand Down Expand Up @@ -1231,6 +1283,7 @@ class GameState {
if (pendingGachaBatch != null)
'pendingGachaBatch': pendingGachaBatch!.toMap(),
if (checkin != null) 'checkin': checkin!.toMap(),
if (karaoke != null) 'karaoke': karaoke!.toMap(),
};
}

Expand Down Expand Up @@ -1285,6 +1338,7 @@ class GameState {
PendingGachaBatch? pendingGachaBatch,
bool? clearPendingGachaBatch,
CheckinState? checkin,
KaraokeState? karaoke,
}) {
return GameState(
saveVersion: saveVersion ?? this.saveVersion,
Expand All @@ -1306,6 +1360,7 @@ class GameState {
? null
: (pendingGachaBatch ?? this.pendingGachaBatch),
checkin: checkin ?? this.checkin,
karaoke: karaoke ?? this.karaoke,
);
}

Expand Down Expand Up @@ -1341,7 +1396,8 @@ class GameState {
other.gacha == gacha &&
other.titles == titles &&
_pendingBatchEquals(other.pendingGachaBatch, pendingGachaBatch) &&
other.checkin == checkin;
other.checkin == checkin &&
other.karaoke == karaoke;
}

@override
Expand All @@ -1361,7 +1417,8 @@ class GameState {
(gacha?.hashCode ?? 0) ^
(titles?.hashCode ?? 0) ^
(pendingGachaBatch?.hashCode ?? 0) ^
(checkin?.hashCode ?? 0);
(checkin?.hashCode ?? 0) ^
(karaoke?.hashCode ?? 0);
}

bool _mapEquals(Map<String, int> a, Map<String, int> b) {
Expand Down
2 changes: 0 additions & 2 deletions lib/services/audio_download_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,6 @@ class AudioDownloadService {
final ok = File(okPath);
final exists = f.existsSync() && (await f.length()) > 0;
final valid = exists && ok.existsSync();
// ignore: avoid_print
print('[AudioDownloadService] isCached($songId) -> file=$exists ok=${ok.existsSync()}');
return valid;
}

Expand Down
Loading