PageSourceSearch

https://typingpet-2026.web.app/js/game.js

js typingpet-2026.web.app collected 2026-10-03 12:38:44 UTC 9,320 bytes, 238 lines download raw bytes

1// ============================================================================
2// game.js —— 養成 / 進化邏輯 + 關卡與寵物資料載入
3//   等級曲線:XP 門檻 [0,300,800,1600,2800,4500] 對應階段 1-6
4// ============================================================================
5
6(function () {
7  "use strict";
8
9  var Game = {
10    lessons: null,     // { series:[], lessons:[] }
11    creatures: null,   // { xpThresholds, stageNames, lines:[] }
12    articles: null,    // { series:{}, articles:[] }(task-006 文章實戰)
13
14    load: async function () {
15      var a = await fetch("data/lessons.json").then(function (r) { return r.json(); });
16      var b = await fetch("data/creatures.json").then(function (r) { return r.json(); });
17      var c = await fetch("data/articles.json").then(function (r) { return r.json(); });
18      this.lessons = a;
19      this.creatures = b;
20      this.articles = c;
21      return this;
22    },
23
24    getLine: function (lineId) {
25      return this.creatures.lines.filter(function (l) { return l.id === lineId; })[0] || null;
26    },
27
28    getLesson: function (lessonId) {
29      return this.lessons.lessons.filter(function (l) { return l.id === lessonId; })[0] || null;
30    },
31
32    // ---- 文章實戰(task-006)----
33    getArticle: function (id) {
34      var list = (this.articles && this.articles.articles) || [];
35      return list.filter(function (a) { return a.id === id; })[0] || null;
36    },
37
38    // 依歷史最佳速度推薦長度檔位(凍結門檻:<20 短(1) / 20–35 中(2) / >35 長(3);英打 WPM、中打 CPM 同)
39    recommendedLevel: function (speed) {
40      speed = speed || 0;
41      if (speed < 20) return 1;
42      if (speed <= 35) return 2;
43      return 3;
44    },
45
46    levelLabel: function (lv) {
47      return lv === 1 ? "短" : (lv === 2 ? "中" : "長");
48    },
49
50    // 依 XP 求目前階段(1-6)
51    stageForXp: function (xp) {
52      var t = this.creatures.xpThresholds;
53      var stage = 1;
54      for (var i = 0; i < t.length; i++) {
55        if (xp >= t[i]) stage = i + 1;
56      }
57      return stage;
58    },
59
60    stageInfo: function (lineId, stage) {
61      var line = this.getLine(lineId);
62      if (!line) return null;
63      return line.stages[stage - 1] || null;
64    },
65
66    stageName: function (stage) {
67      return this.creatures.stageNames[stage - 1] || ("階段 " + stage);
68    },
69
70    // --- 等級(Lv 1–15,2026-09-18)--------------------------------------
71    // 「外型」仍是上面的 stage(1–6,決定進化圖,排行榜與 rules 也只認它);
72    // 「等級」是獨立的一條線:Lv 1–6 門檻與 stage 相同(沒有人降級),
73    // Lv 7–15 外型停在最終形態、靠稱號區分。學生回報太快滿級,使用者裁定拉到 15 級。
74    levelThresholds: function () {
75      return this.creatures.levelThresholds || this.creatures.xpThresholds;
76    },
77
78    levelForXp: function (xp) {
79      var t = this.levelThresholds();
80      var level = 1;
81      for (var i = 0; i < t.length; i++) {
82        if (xp >= t[i]) level = i + 1;
83      }
84      return level;
85    },
86
87    levelName: function (level) {
88      var names = this.creatures.levelNames || this.creatures.stageNames;
89      return names[level - 1] || ("Lv " + level);
90    },
91
92    maxLevel: function () {
93      return this.levelThresholds().length;
94    },
95
96    // 本級進度:{ level, isMax, curThreshold, nextThreshold, toNext, percent }
97    levelProgress: function (xp) {
98      var t = this.levelThresholds();
99      var level = this.levelForXp(xp);
100      var cur = t[level - 1];
101      var isMax = level >= t.length;
102      var next = isMax ? cur : t[level];
103      return {
104        level: level,
105        isMax: isMax,
106        curThreshold: cur,
107        nextThreshold: next,
108        toNext: isMax ? 0 : next - xp,
109        percent: isMax ? 100 : Math.round(Math.min(100, ((xp - cur) / (next - cur)) * 100))
110      };
111    },
112
113    // 加 XP,回傳 { xp, oldStage, newStage, evolved }
114    addXp: function (profile, amount) {
115      var oldStage = this.stageForXp(profile.xp);
116      var oldLevel = this.levelForXp(profile.xp);
117      profile.xp += amount;
118      var newStage = this.stageForXp(profile.xp);
119      var newLevel = this.levelForXp(profile.xp);
120      return {
121        xp: profile.xp,
122        oldStage: oldStage,
123        newStage: newStage,
124        evolved: newStage > oldStage,     // 外型變了(換進化圖)
125        oldLevel: oldLevel,
126        newLevel: newLevel,
127        leveledUp: newLevel > oldLevel     // 等級升了(Lv 7 起外型不變、只換稱號)
128      };
129    },
130
131    // ======================================================================
132    // v1.2 進步系統(task-004)—— 數值全部凍結,見 SPEC「v1.2 進步系統」
133    // ======================================================================
134
135    HISTORY_MAX: 300,
136
137    // 逐次結算 append 一筆歷史,超過 300 丟最舊(client 端裁剪)
138    pushHistory: function (profile, entry) {
139      if (!Array.isArray(profile.history)) profile.history = [];
140      profile.history.push(entry);
141      while (profile.history.length > this.HISTORY_MAX) profile.history.shift();
142      return profile.history;
143    },
144
145    // 三星速度門檻(凍結):series → { two:{spd,acc}, three:{spd,acc} }
146    // 英打鍵位放寬;英打單字/句子;中打三系列。速度:英打 WPM、中打 CPM。
147    STAR_THRESHOLDS: {
148      "keys":         { two: { spd: 20, acc: 90 }, three: { spd: 30, acc: 95 } },
149      "words":        { two: { spd: 25, acc: 90 }, three: { spd: 35, acc: 95 } },
150      "sentences":    { two: { spd: 25, acc: 90 }, three: { spd: 35, acc: 95 } },
151      "zh-chars":     { two: { spd: 25, acc: 90 }, three: { spd: 40, acc: 95 } },
152      "zh-words":     { two: { spd: 25, acc: 90 }, three: { spd: 40, acc: 95 } },
153      "zh-sentences": { two: { spd: 25, acc: 90 }, three: { spd: 40, acc: 95 } }
154    },
155
156    // 單次結算判星(完成該關至少 1★;達門檻升 2★/3★)
157    starFor: function (seriesId, speed, accPct) {
158      var t = this.STAR_THRESHOLDS[seriesId];
159      if (!t) return 1;
160      if (speed >= t.three.spd && accPct >= t.three.acc) return 3;
161      if (speed >= t.two.spd && accPct >= t.two.acc) return 2;
162      return 1;
163    },
164
165    // 首次升星一次性獎勵 XP:2★ +20、3★ +50(逐階,跨階累加;同星不重發)
166    starBonus: function (prevStar, newStar) {
167      var bonus = 0;
168      for (var t = (prevStar || 0) + 1; t <= newStar; t++) {
169        if (t === 2) bonus += 20;
170        else if (t === 3) bonus += 50;
171      }
172      return bonus;
173    },
174
175    // 記錄本關最高星並回傳 { prevStar, newStar, bonus }
176    recordStar: function (profile, lessonId, star) {
177      if (!profile.stars || typeof profile.stars !== "object") profile.stars = {};
178      var prev = profile.stars[lessonId] || 0;
179      var next = Math.max(prev, star);
180      var bonus = this.starBonus(prev, next);
181      profile.stars[lessonId] = next;
182      return { prevStar: prev, newStar: next, bonus: bonus };
183    },
184
185    // ---- 台北時區(UTC+8,無日光節約)本週一 00:00 的 UTC 毫秒 ----
186    taipeiWeekStart: function (nowMs) {
187      var OFF = 8 * 3600 * 1000;
188      var d = new Date((nowMs == null ? Date.now() : nowMs) + OFF); // 以 UTC 方法讀出=台北本地
189      var day = d.getUTCDay();                 // 0=日..6=六
190      var sinceMon = (day === 0) ? 6 : day - 1;
191      var midnight = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
192      return midnight - sinceMon * 86400000 - OFF; // 回推成真實 UTC 毫秒
193    },
194
195    // 某模式的週增幅:{ gain, weekBest, priorBest, mode } 或 null(無本週或無先前歷史)
196    modeGain: function (history, mode, weekStart) {
197      var weekBest = -Infinity, priorBest = -Infinity;
198      (history || []).forEach(function (h) {
199        if (h.mode !== mode) return;
200        if (h.t >= weekStart) { if (h.speed > weekBest) weekBest = h.speed; }
201        else { if (h.speed > priorBest) priorBest = h.speed; }
202      });
203      if (weekBest === -Infinity || priorBest === -Infinity) return null;
204      return { gain: weekBest - priorBest, weekBest: weekBest, priorBest: priorBest, mode: mode };
205    },
206
207    // 取英打/中打兩模式中增幅較大者當排序值;皆無效回 null
208    weeklyGain: function (profile, nowMs) {
209      var ws = this.taipeiWeekStart(nowMs);
210      var en = this.modeGain(profile.history, "en", ws);
211      var zh = this.modeGain(profile.history, "zh", ws);
212      if (!en && !zh) return null;
213      if (en && zh) return en.gain >= zh.gain ? en : zh;
214      return en || zh;
215    },
216
217    // ---- 閃光色 endgame ----
218    shinyUnlocked: function (profile) {
219      var line = this.getLine(profile.creatureId);
220      if (!line || typeof line.shinyXp !== "number") return false;
221      return profile.xp >= line.shinyXp;
222    },
223
224    // 閃光顯示資訊 { img, name, stageLabel }(同名加 ✨)
225    shinyInfo: function (lineId) {
226      var line = this.getLine(lineId);
227      if (!line || !line.shinyImg) return null;
228      var s6 = line.stages[line.stages.length - 1];
229      return {
230        img: line.shinyImg,
231        name: s6.name + " ✨",
232        stageLabel: "王者・閃光"
233      };
234    }
235  };
236
237  window.Game = Game;
238})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.