PageSourceSearch

https://psmud.net/js/world-state.js?v=1790651596

js psmud.net collected 2026-10-06 20:01:44 UTC 44,953 bytes, 1,173 lines download raw bytes

1/* world-state.js —— 首頁「此刻的原始物語」的**單一真相**
2   (UMD:瀏覽器掛 window.PS_WORLD、Node 端 require;同 class-data.js / nav-data.js 的模式)。
3
4   為什麼要 UMD:SSR 與前端**都**要做同一件事 —— 從 world.json 的 phases 找出當前時段、
5   算日晷插值、把 id 翻成中文、組出那幾句話。兩邊各寫一套就是這個站踩過最多次的坑
6   (help 遷移那輪的結論:兩端各寫一套 parser 才是雙端漂移的真正來源)。
7
8   分工:
9     worldstate.js(repo 根)   讀檔、快取、過期判定 —— 只管「拿到資料」
10     本檔                      值域中文表、插值、組句、SSR 的 HTML —— 只管「怎麼呈現」
11     static/js/index.js        前端倒數與校正 —— 只管「把新的值塞回 DOM」
12
13   ⚠ 這裡**不推算** season/moon/weather:那些是 MUD 給的結論,網站端自己算就會漂移
14     (WORLD_STATE_PLAN §3-3)。唯一算出來的是「距離下一個時段還有多久」,
15     而它只用到 world.json 自己的 phases 與 day_len。
16*/
17(function(root, factory) {
18	if (typeof module === "object" && module.exports) module.exports = factory();
19	else root.PS_WORLD = factory();
20})(typeof self !== "undefined" ? self : this, function() {
21	"use strict";
22
23	/* ================= 一、日晷的關鍵影格 =================
24	   八組值=八個時段各自「長什麼樣」,中間的時刻由插值補出來。**調圖就是調這張表。**
25
26	   shadow 的欄位對應 CSS:w/h/l/t = width/height/left/top(%),o = opacity,
27	   sy = scaleY,kx/ky = skew 的兩個角度(deg);dial 的 hue/br = filter 的
28	   hue-rotate(deg) 與 brightness。
29
30	   ⚠ 錨點在每個時段的**正中間**,不是起點 —— 理由見 phaseAt/frameAt 上方的說明。
31	   ⚠ 夜裡 opacity 為 0(沒有太陽就沒有影子),但幾何仍然要填:插值要有東西可以走,
32	     否則影子會在傍晚原地扭一下才消失。
33
34	   ⭐ 任一欄位都可以不寫數值,改寫 { 天氣id: 數值, default: 數值 } —— frameAt 會照
35	     world.weather 去查,沒對到的天氣一律退回 default(`default` 這個 key 是必填,
36	     否則沒對到的天氣會算出 NaN)。見 noon.br:雨天/颱風天中午沒那麼亮。
37
38	   pbr = .ws-pin-highlight(晷針受光面)的 filter: brightness() —— 與 dial 的 br 是
39	   兩層各自的亮度,互不影響。全時段初始值都是 1(=不調整),要做「上午受光面比較亮」
40	   之類的效果就直接改對應時段的 pbr。 */
41	var SUNDIAL = {
42		dawn: {
43			w: 26.5,
44			h: 4,
45			l: 41,
46			t: 22,
47			o: 0.2,
48			sy: 1,
49			kx: 84,
50			ky: -26,
51			hue: {
52				"rain": 0,
53				"default": -70
54			},
55			br: 0.8,
56			pbr: 2,
57			sat: 1,
58			gsat: 0.9,
59			ghue: {
60				"heat": -40,
61				"default": 50
62			}
63		},
64		sunrise: {
65			w: 26.5,
66			h: 5,
67			l: 39,
68			t: 20,
69			o: {
70				"rain": 0.2,
71				"drizzle": 0.2,
72				"typhoon": 0.25,
73				"snow": 0.25,
74				"cloudy": 0.25,
75				"cold_wave": 0.25,
76				"default": 0.3,
77				"heat": 0.6
78			},
79			sy: 1,
80			kx: 79,
81			ky: -26,
82			hue: {
83				"rain": 0,
84				"default": -4
85			},
86			br: {
87				// "rain": 0.8,
88				"typhoon": 0.8,
89				"drizzle": 0.8,
90				"snow": 0.8,
91				"cloudy": 0.85,
92				"cold_wave": 0.85,
93				"default": 0.9,
94				"heat": 1.2
95			},
96			pbr: {
97				// "rain": 2.1,
98				"typhoon": 2.1,
99				"drizzle": 2.2,
100				"snow": 2.2,
101				"cloudy": 2.2,
102				"cold_wave": 2.2,
103				"default": 3,
104				"heat": 3.5
105			},
106			sat: 1,
107			gsat: 1,
108			ghue: {
109				"heat": -40,
110				"default": 0
111			}
112		},
113		morning: {
114			w: 26.5,
115			h: 5,
116			l: 40,
117			t: 19,
118			o: {
119				"rain": 0.2,
120				"drizzle": 0.2,
121				"typhoon": 0.25,
122				"snow": 0.25,
123				"cloudy": 0.25,
124				"cold_wave": 0.25,
125				"default": 0.4,
126				"heat": 0.7
127			},
128			sy: 1,
129			kx: 67,
130			ky: -26,
131			hue: 0,
132			br: {
133				// "rain": 0.8,
134				"typhoon": 0.8,
135				"drizzle": 0.85,
136				"snow": 0.9,
137				"cloudy": 0.9,
138				"cold_wave": 1,
139				"default": 1.1,
140				"heat": 1.5
141			},
142			pbr: {
143				// "rain": 1.7,
144				"typhoon": 1.7,
145				"drizzle": 1.7,
146				"snow": 2,
147				"cloudy": 2,
148				"cold_wave": 2,
149				"default": 2.5,
150				"heat": 3
151			},
152			sat: 1,
153			gsat: 1,
154			ghue: {
155				"heat": -40,
156				"default": 0
157			}
158		},
159		noon: {
160			w: 26,
161			h: 0.1,
162			l: 41,
163			t: 26,
164			o: {
165				"rain": 0.2,
166				"drizzle": 0.25,
167				"typhoon": 0.3,
168				"snow": 0.3,
169				"cloudy": 0.3,
170				"cold_wave": 0.4,
171				"default": 0.8,
172				"heat": 0.8
173			},
174			sy: -1,
175			kx: -89,
176			ky: 26,
177			hue: {
178				"rain": 0,
179				"default": 4
180			},
181			br: {
182				// "rain": 0.9,
183				"typhoon": 0.9,
184				"drizzle": 0.9,
185				"snow": 1,
186				"cloudy": 1,
187				"cold_wave": 1.1,
188				"default": 1.4,
189				"heat": 1.6
190			},
191			pbr: {
192				// "rain": 1,
193				"typhoon": 1,
194				"drizzle": 1,
195				"snow": 1,
196				"cloudy": 1,
197				"cold_wave": 1,
198				"default": 1.4,
199				"heat": 1.1
200			},
201			sat: 1,
202			gsat: 1,
203			ghue: {
204				"heat": -40,
205				"default": 0
206			}
207		},
208		afternoon: {
209			w: 26.5,
210			h: 3,
211			l: 41,
212			t: 23.5,
213			o: {
214				"rain": 0.2,
215				"drizzle": 0.2,
216				"typhoon": 0.25,
217				"snow": 0.25,
218				"cloudy": 0.25,
219				"cold_wave": 0.25,
220				"default": 0.4,
221				"heat": 0.7
222			},
223			sy: -1,
224			kx: -74,
225			ky: 26,
226			hue: {
227				"rain": 0,
228				"default": -15
229			},
230			br: {
231				"typhoon": 0.8,
232				"drizzle": 0.85,
233				"snow": 0.95,
234				"cloudy": 0.95,
235				"cold_wave": 1.05,
236				"default": 1.2,
237				"heat": 1.4
238			},
239			pbr: 1,
240			sat: 1,
241			gsat: 1,
242			ghue: {
243				"heat": -40,
244				"default": 0
245			}
246		},
247		evening: {
248			w: 26.5,
249			h: 15,
250			l: 41,
251			t: 11,
252			o: {
253				"rain": 0.1,
254				"drizzle": 0.1,
255				"typhoon": 0.15,
256				"snow": 0.15,
257				"cloudy": 0.15,
258				"cold_wave": 0.15,
259				"default": 0.2,
260				"heat": 0.5
261			},
262			sy: -1,
263			kx: -37,
264			ky: 26,
265			hue: {
266				"rain": 0,
267				"default": -35
268			},
269			br: {
270				"typhoon": 0.6,
271				"drizzle": 0.65,
272				"snow": 0.65,
273				"cloudy": 0.6,
274				"cold_wave": 0.7,
275				"default": 0.8,
276				"heat": 1
277			},
278			pbr: 1,
279			sat: 2,
280			gsat: 0.8,
281			ghue: {
282				"heat": -40,
283				"default": 0
284			}
285		},
286		night: {
287			w: 26.5,
288			h: 22,
289			l: 41,
290			t: 4,
291			o: 0,
292			sy: -1,
293			kx: 0,
294			ky: 26,
295			hue: {
296				"rain": 0,
297				"default": -180
298			},
299			br: 0.4,
300			pbr: 1,
301			sat: 1,
302			gsat: 0.8,
303			ghue: {
304				"heat": -0,
305				"default": 160
306			}
307		},
308		midnight: {
309			w: 26.5,
310			h: 4,
311			l: 41,
312			t: 22,
313			o: 0,
314			sy: 1,
315			kx: 87,
316			ky: -26,
317			hue: {
318				"rain": 0,
319				"default": -122
320			},
321			br: 0.3,
322			pbr: 1,
323			sat: 1,
324			gsat: 0.7,
325			ghue: {
326				"heat": -0,
327				"default": 160
328			}
329		}
330	};
331
332	var FIELDS = ['w', 'h', 'l', 't', 'o', 'sy', 'kx', 'ky', 'hue', 'br', 'pbr', 'sat', 'gsat', 'ghue'];
333
334	/* ================= 二、中文顯示名 =================
335	   ⚠ 這幾張表是**網站端的**,MUD 只給 ascii id;少一個就會在頁面上變成裸 token
336	     (WORLD_STATE_PLAN §4-4)。 */
337	var CN_PHASE = {
338		dawn: '黎明',
339		sunrise: '日出',
340		morning: '上午',
341		noon: '中午',
342		afternoon: '下午',
343		evening: '傍晚',
344		night: '夜晚',
345		midnight: '凌晨'
346	};
347	var CN_WEATHER = {
348		clear: '晴朗',
349		cloudy: '陰天',
350		breeze: '微風',
351		drizzle: '細雨',
352		rain: '大雨',
353		typhoon: '颱風',
354		snow: '下雪',
355		cold_wave: '寒流',
356		heat: '高溫'
357	};
358	// ⚠ 中文的「新月」不是天文學的 new moon(那是朔月),照這張表不要照字面翻
359	var CN_MOON = {
360		new_moon: '朔月',
361		waxing_crescent: '新月',
362		first_quarter: '上弦月',
363		waxing_gibbous: '漸盈月',
364		full_moon: '滿月',
365		waning_gibbous: '虧凸月',
366		last_quarter: '下弦月',
367		waning_crescent: '殘月'
368	};
369	var CN_SEASON = {
370		spring: '春',
371		summer: '夏',
372		autumn: '秋',
373		winter: '冬'
374	};
375	var CN_PALACE = {
376		aries: '牡羊',
377		taurus: '金牛',
378		gemini: '雙子',
379		cancer: '巨蟹',
380		leo: '獅子',
381		virgo: '處女',
382		libra: '天秤',
383		scorpio: '天蠍',
384		sagittarius: '射手',
385		capricorn: '摩羯',
386		aquarius: '水瓶',
387		pisces: '雙魚'
388	};
389	var CN_NUM = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二'];
390
391	/* badge 圖面。天氣九種+彩虹+十二星座+月相八種+冰河期+火山已換成像素圖(image/sundial/*.png,ART_STYLE_GUIDE §1-2),
392	   face 留空、改由 CSS 依 data-weather / data-kind=rainbow / data-
392palace / data-moon / data-kind=ice / data-kind=volcano
393	   用 background-image 畫(見 library.css 對應選擇器)。極光(aurora)圖已備好在 image/sundial/,但還沒有對應的
394	   badge 資料欄位或 kind,先不接——等 gameplay 端真的有這個事件再照這個模式接進來。 */
395	var FACE_WEATHER = {
396		clear: '',
397		cloudy: '',
398		breeze: '',
399		drizzle: '',
400		rain: '',
401		typhoon: '',
402		snow: '',
403		cold_wave: '',
404		heat: ''
405	};
406	var FACE_MOON = {
407		new_moon: '',
408		waxing_crescent: '',
409		first_quarter: '',
410		waxing_gibbous: '',
411		full_moon: '',
412		waning_gibbous: '',
413		last_quarter: '',
414		waning_crescent: ''
415	};
416	var FACE_PALACE = {
417		aries: '',
418		taurus: '',
419		gemini: '',
420		cancer: '',
421		leo: '',
422		virgo: '',
423		libra: '',
424		scorpio: '',
425		sagittarius: '',
426		capricorn: '',
427		aquarius: '',
428		pisces: ''
429	};
430
431	/* 天氣特效層(雨/雪/風吹落葉/上浮光斑共用)的強度表:同一種天氣一組數字,cls 決定套哪套視覺
432	   (雨絲/雪花/落葉/光斑)—— 要調「下得多大、風多強、霧多濃」改這張表就好,不必碰產生邏輯
433	   (weatherParticlesHtml)。落下角度/雪花外觀/落葉形狀/光斑顏色是 CSS 的事(見 library.css 的
434	   .wx-<weather> .ws-rain-drop / .ws-snow-flake / .ws-leaf / .ws-glow),因為那對同一種天氣
435	   永遠是同一個值,不必逐滴算。
436	   ⚠ ui.js(產生首頁的效果層)與 sundial.html(沙盒逐幀重繪)兩邊都要用同一份強度表,
437	     各寫一份就是這個檔案開頭說的那個坑,所以放在這裡當唯一真相。
438	   雨用 len/w(雨絲長度/粗細);雪/光斑用 len 當直徑、多一個 swayMax(左右飄的振幅,px);
439	   落葉用 len 當葉片大小、多一個 bobMax(飛過去時上下晃動的振幅,px)。
440	   光斑(熱氣)跟雪花共用同一套「x 定橫向位置+sway 左右飄」的生成邏輯,只是方向反過來
441	   由 CSS 的 keyframes 決定往上飄,不是往下掉,所以 JS 這邊不用另外分支。
442	   ⭐ 值也可以是**陣列**(同一種天氣疊多層效果,weatherParticlesHtml 會攤平陣列、每層
443	   各自灑一批再合併成一份 HTML)——目前沒有天氣在用,曾經是 rain/drizzle 的水花層,
444	   拿掉了但產生邏輯留著,之後要疊別的層可以直接用。
445	   細雨=大雨滴數的一半、微風=颱風葉片數的 35%,兩組都用公式從主天氣算,不寫死第二個數字。
446	   ⭐ `nightScale`(選填)=**入夜之後這一層的隻數要乘上多少**(見 heat)。這是強度表裡
447	   唯一一個跟時間有關的欄位,配套的是 weatherKey:有這個欄位的天氣,鍵才會帶日夜。 */
448	var RAIN_N = 63; // 大雨的滴數(原本 42 的 1.5 倍,大雨強度 +50%)
449	var LEAF_N = 12; // 颱風的葉片數
450	var PARTICLE_CFG = {
451		drizzle: {
452			cls: 'ws-rain-drop',
453			n: Math.round(RAIN_N * 0.5),
454			durMin: 1.3,
455			durMax: 1.9,
456			opMin: .35, // 透明度/粗細與大雨一致(使用者要求),只用滴數/長度/速度分辨兩種雨
457			opMax: .7,
458			lenMin: 7,
459			lenMax: 13,
460			wMax: 2
461		},
462		rain: {
463			cls: 'ws-rain-drop',
464			n: RAIN_N,
465			durMin: 0.45,
466			durMax: 0.75,
467			opMin: .35,
468			opMax: .7,
469			lenMin: 16,
470			lenMax: 28,
471			wMax: 2
472		},
473		snow: {
474			cls: 'ws-snow-flake',
475			n: 36,
476			durMin: 3.5,
477			durMax: 6.5,
478			opMin: .5,
479			opMax: .95,
480			lenMin: 4,
481			lenMax: 9,
482			swayMax: 22
483		},
484		breeze: {
485			cls: 'ws-leaf',
486			n: Math.round(LEAF_N * 0.4),
487			durMin: 3.2,
488			durMax: 5.0,
489			opMin: .55,
490			opMax: .85,
491			lenMin: 11,
492			lenMax: 18,
493			bobMax: 14
494		},
495		typhoon: {
496			cls: 'ws-leaf',
497			n: LEAF_N,
498			durMin: 0.9,
499			durMax: 1.6,
500			opMin: .55,
501			opMax: .85,
502			lenMin: 11,
503			lenMax: 18,
504			bobMax: 30
505		},
506		// 熱氣:改成跟寒流一樣大片模糊的風格(顏色仍是暖橘、方向仍是上浮,只是體積/模糊/透明度比照
507		// PARTICLE_CFG.cold_wave 那組「大而糊而淡」的數字調,不是原本那種小顆亮點光斑)
508		heat: {
509			cls: 'ws-glow',
510			n: 8,
511			durMin: 5.25, // 再慢 50%(原 3.5)
512			durMax: 8.25, // 再慢 50%(原 5.5)
513			opMin: .22,
514			opMax: .45,
515			lenMin: 40, // 100% 基準
516			lenMax: 80, // 200%(原 68 調整為基準的兩倍)
517			swayMax: 12,
518			// ⭐ 入夜(夜晚/凌晨)之後熱氣退去一半 —— 目前唯一一個**會隨時段變**的天氣強度。
519			// 任何一組都可以加這個欄位;加了之後 weatherKey 才會把日夜寫進鍵,那一層才會在入夜時重灑
520			// (沒加的天氣鍵裡不帶日夜 —— 否則雨/雪會在傍晚白白重灑一批,整片雨絲同時歸零)。
521			nightScale: 0.5
522		},
523		// 寒流:大片模糊、很淡的白霧橫向飄過畫面(邊緣暈影版本邊界太生硬,改回粒子);
524		// 跟落葉共用「y 定縱向位置+左右橫飄」那組欄位(bobMax 這裡是飄過去時的上下起伏,不是翻滾)
525		cold_wave: {
526			cls: 'ws-mist',
527			n: 8,
528			durMin: 7,
529			durMax: 11,
530			opMin: .22,
531			opMax: .45,
532			lenMin: 46,
533			lenMax: 78,
534			bobMax: 10
535		} // opMin/opMax 調高、n 調高:原本 .06~.16 配 20px 重模糊,alpha 被攤得太薄,對日晷幾乎沒視覺影響
536	};
537
538	/* 螢火蟲 —— **環境效果**,不是天氣效果(2026-09-07 新增),所以另外一張表。
539	   PARTICLE_CFG 的鍵是天氣 id、一種天氣一組數字;螢火蟲的出沒卻是三個條件同時成立:
540	     ① 時段:整個「夜晚」+「凌晨」的前 FIREFLY_CUT(=前半)
541	     ② 天氣:沒有風雨的四種(晴朗/陰天/微風/高溫)—— 下雨颳風下雪的夜裡沒有螢火蟲
542	     ③ 季節:夏季最多,春/秋 60%、冬季 30%(FIREFLY_SEASON 的倍率)
543	   ⚠ 這是第一個**會在同一種天氣裡隨時間出沒**的效果 —— 天氣層只在天氣變動時重建,
544	     這層要每秒比對 fireflyKey(見 world-state-ui.js 的 buildEnvFx),兩層不能混在一起。
545	   ⚠ 隻數只寫一個基準 FIREFLY_N,其餘季節一律用倍率算出來,不寫死第二個數字
546	     ——與「細雨=大雨的一半、微風=颱風的 35%」同一個做法,要調整只改一處。
547	   ⚠ 冰河期(world.ice_age)一律不出現:冰河期的天氣可能是陰天(算平靜),但
548	     「冰河期籠罩大地」與滿場螢火蟲是打架的敘事。要放行就把 fireflyOn 那一行拿掉。
549	   粒子欄位與雪花那組相近,多兩組:--dx/--dy 是漂移的方向與距離(px,正負皆有),
550	   --bdur/--bdelay 是**閃爍**自己的週期 —— 漂移與閃爍是兩條各自獨立的 animation,
551	   週期不同步才像真的螢火蟲(見 library.css 的 wsFireflyDrift / wsFireflyBlink)。
552	   --y 灑滿整層的高度(yMin~yMax 幾乎是 0~100):第一版只灑下半(40~92)想做「貼著草叢飛」,
553	   實際看起來是整群擠在中間偏下的一條帶子 —— 螢火蟲該是散在整個畫面裡的。
554	   ⚠ 上下各留 3% 是因為每隻還會再漂移 ±driftMax(px):貼著邊生成的那幾隻有一半時間在框外
555	     (那一層 overflow:hidden,不會露出去,只是白灑)。 */
556	var FIREFLY_N = 18; // 夏季的隻數(基準)
557	var FIREFLY_SEASON = {
558		summer: 1,
559		spring: 0.6,
560		autumn: 0.6,
561		winter: 0.3
562	};
563	// 平靜天氣:晴朗/陰天/微風/高溫。⚠ 高溫是 9/7 補的 —— 它自己也有一層效果(上浮的暖橘光斑,
564	// PARTICLE_CFG.heat),兩層會同時在:光斑在 .ws-wxfx(z-index 4)、螢火蟲在 .ws-envfx(5),不會互相蓋掉。
565	var FIREFLY_CALM = ['clear', 'cloudy', 'breeze', 'heat'];
566	var FIREFLY_CUT = 0.5; // 凌晨過了這個比例就散去
567	var FIREFLY_CFG = {
568		cls: 'ws-firefly',
569		durMin: 6, // 漂移一趟的秒數(慢:螢火蟲是飄的,不是飛的)
570		durMax: 11,
571		opMin: .55, // 閃到最亮時的透明度
572		opMax: 1,
573		lenMin: 4.5, // 直徑(px)。9/7 使用者要求整體放大 50%(原 3~6)——
574		lenMax: 9, //  光暈(library.css 的 drop-shadow)刻意沒跟著放大,那是固定寬度的暈,放大會糊掉
575		driftMax: 46, // --dx/--dy 的振幅上限(px)
576		yMin: 3, // 縱向範圍(%):幾乎是整層的高度,不是只有下半
577		yMax: 97,
578		blinkMin: 1.6, // 一次明滅的秒數
579		blinkMax: 3.4
580	};
581
582	/* ================= 三、小工具 ================= */
583
584	var esc = function(s) {
585		return String(s == null ? '' : s)
586			.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
587	};
588
589	// 秒 → 「3 分 20 秒」/「45 秒」/「39 分」(超過十分鐘就不報秒,免得一個不重要的數字一直跳)
590	var fmtDur = function(sec) {
591		sec = Math.max(0, Math.round(sec));
592		var m = Math.floor(sec / 60),
593			s = sec % 60;
594		if (m >= 10) return m + ' 分';
595		if (m) return m + ' 分' + (s ? ' ' + s + ' 秒' : '');
596		return s + ' 秒';
597	};
598
599	var lerp = function(a, b, u) {
600		return a + (b - a) * u;
601	};
602
603	// 淺複製一組強度設定(要改其中一兩個數字又不能動到表本身:表是模組層的單一真相,改了會累積)
604	var copyCfg = function(src, over) {
605		var out = {};
606		Object.keys(src).forEach(function(k) { out[k] = src[k]; });
607		Object.keys(over || {}).forEach(function(k) { out[k] = over[k]; });
608		return out;
609	};
610
611	// 依單一層強度隨機灑一批粒子(雨絲/雪花/落葉/光斑/霧團/水花,由 cfg.cls 決定)。
612	var layerHtml = function(cfg) {
613		var html = '';
614		for (var i = 0; i < cfg.n; i++) {
615			var dur = cfg.durMin + Math.random() * (cfg.durMax - cfg.durMin);
616			var vars = '--op:' + (cfg.opMin + Math.random() * (cfg.opMax - cfg.opMin)).toFixed(2) + ';' +
617				'--dur:' + dur.toFixed(2) + 's;' +
618				'--delay:-' + (Math.random() * dur).toFixed(2) + 's;';
619			if (cfg.cls === 'ws-snow-flake' || cfg.cls === 'ws-glow') {
620				// 雪/光斑:left% 定橫向位置、len 當直徑、sway 是左右飄的振幅(正負皆有可能);
621				// 往上飄還是往下掉是 CSS keyframes 的事(見 wsSnowFall / wsGlowRise),這裡兩者共用同一組欄位。
622				vars += '--x:' + (Math.random() * 100).toFixed(1) + '%;' +
623					'--size:' + (cfg.lenMin + Math.random() * (cfg.lenMax - cfg.lenMin)).toFixed(1) + 'px;' +
624					'--sway:' + ((Math.random() * 2 - 1) * cfg.swayMax).toFixed(0) + 'px;';
625			} else if (cfg.cls === 'ws-leaf' || cfg.cls === 'ws-mist') {
626				// 落葉/霧團:橫著被風吹過去,top% 定縱向位置;len 當大小、bob 是飄過去時上下起伏的振幅
627				// (落葉的 bob 配翻滾 rotate 是「打轉」,霧團的 bob 沒有 rotate 是單純「起伏」,差異在 CSS)
628				vars += '--y:' + (Math.random() * 100).toFixed(1) + '%;' +
629					'--size:' + (cfg.lenMin + Math.random() * (cfg.lenMax - cfg.lenMin)).toFixed(1) + 'px;' +
630					'--bob:' + ((Math.random() * 2 - 1) * cfg.bobMax).toFixed(0) + 'px;';
631			} else if (cfg.cls === 'ws-firefly') {
632				// 螢火蟲:x/y 定位置(y 的範圍見 FIREFLY_CFG.yMin/yMax,涵蓋整層高度)、len 當直徑,
633				// dx/dy 是漂移的方向與距離(正負皆有可能),bdur/bdelay 是**閃爍**自己的週期 ——
634				// 漂移(--dur)與閃爍(--bdur)是兩條獨立的 animation,不同步才像真的螢火蟲。
635				var bdur = cfg.blinkMin + Math.random() * (cfg.blinkMax - cfg.blinkMin);
636				vars += '--x:' + (Math.random() * 100).toFixed(1) + '%;' +
637					'--y:' + (cfg.yMin + Math.random() * (cfg.yMax - cfg.yMin)).toFixed(1) + '%;' +
638					'--size:' + (cfg.lenMin + Math.random() * (cfg.lenMax - cfg.lenMin)).toFixed(1) + 'px;' +
639					'--dx:' + ((Math.random() * 2 - 1) * cfg.driftMax).toFixed(0) + 'px;' +
640					'--dy:' + ((Math.random() * 2 - 1) * cfg.driftMax).toFixed(0) + 'px;' +
641					'--bdur:' + bdur.toFixed(2) + 's;' +
642					'--bdelay:-' + (Math.random() * bdur).toFixed(2) + 's;';
643			} else {
644				// 雨:從上面掉下來,left% 定橫向位置;len/w 是雨絲的長度與粗細
645				vars += '--x:' + (Math.random() * 100).toFixed(1) + '%;' +
646					'--len:' + (cfg.lenMin + Math.random() * (cfg.lenMax - cfg.lenMin)).toFixed(0) + 'px;' +
647					'--w:' + (1 + Math.random() * (cfg.wMax - 1)).toFixed(1) + 'px;';
648			}
649			html += '<i class="' + cfg.cls + '" style="' + vars + '"></i>';
650		}
651		return html;
652	};
653
654	// 呼叫端可能只給天氣 id(舊寫法),也可能給整包 world —— 後者才拿得到時段,
655	// 才知道要不要套 nightScale。統一在這裡收斂成 { weather: ... }。
656	var asWorld = function(world) {
657		return (world && typeof world === 'object') ? world : { weather: world };
658	};
659
660	/* 依 PARTICLE_CFG 的強度隨機灑一批粒子;天氣沒對到任何一組就回傳空字串
661	   (呼叫端決定要不要連容器一起拆掉)。值可以是單層 config,也可以是**陣列**
662	   (同一種天氣疊多層效果,例如 rain/drizzle 除了雨絲還要加水花),兩種都吃。
663	   ⚠ 第一個參數請給 **world 物件**(配第二個參數 elapsed):只給天氣 id 的舊寫法仍然可用,
664	     但那樣拿不到時段,`nightScale` 就不會生效(高溫入夜後不會減半)。 */
665	var weatherParticlesHtml = function(world, elapsed) {
666		var w = asWorld(world);
667		var cfg = PARTICLE_CFG[w.weather];
668		if (!cfg) return '';
669		var night = (world && typeof world === 'object') && isNightPhase(w, elapsed);
670		var layers = Array.isArray(cfg) ? cfg : [cfg];
671		return layers.map(function(one) {
672			if (!night || one.nightScale == null) return layerHtml(one);
673			return layerHtml(copyCfg(one, { n: Math.round(one.n * one.nightScale) }));
674		}).join('');
675	};
676
677	/* 給呼叫端判斷「天氣這一層要不要重建」用的鍵(同 fireflyKey 的角色)。
678	   ⭐ 只有**帶 nightScale 的天氣**才把日夜寫進鍵 —— 一律寫的話,雨/雪也會在入夜那一秒
679	     整層重灑,滿畫面的雨絲同時歸零回到起點,看得出來。 */
680	var weatherKey = function(world, elapsed) {
681		var w = asWorld(world);
682		var cfg = PARTICLE_CFG[w.weather];
683		if (!cfg) return String(w.weather);
684		var layers = Array.isArray(cfg) ? cfg : [cfg];
685		var timed = layers.some(function(one) { return one.nightScale != null; });
686		if (!timed) return String(w.weather);
687		return w.weather + (isNightPhase(w, elapsed) ? '|night' : '|day');
688	};
689
690	// SUNDIAL 欄位值本來一律是數值,現在也可以是 { 天氣: 數值, default: 數值 } ——
691	// 沒對到 world.weather 的天氣就退回 default(§noon.br 是第一個例子:雨天/颱風天中午比較暗)。
692	// ⚠ 型態用 typeof 判斷,不能只看有沒有這個天氣的 key —— 數值不能當物件索引。
693	var fieldVal = function(v, weather) {
694		if (v !== null && typeof v === 'object')
695			return (weather in v) ? v[weather] : v['default'];
696		return v;
697	};
698
699	/* ================= 四、時段 ================= */
700
701	/* 從 world.phases 長出時段表。**不硬編**:MUD 端那份常數是對外的唯一真相(§3-3)。
702	 * ⚠ 最後一段延伸到 day_len —— 各段起點的總和不一定等於 day_len。
703	 *
704	 * ⭐ **校正**(§3-5-2):`phases` 裡的起點是**名目秒數**,而 `day_len` 是**實測秒數**。
705	 *   遊戲的時段是用 `call_out` 一段一段排的(一天八次),每次的秒級進位累積起來 ——
706	 *   實測 day_len=1208 就是每次遲到約 1 秒 × 8 段。不校正的話網站會一路比遊戲早,
707	 *   到換日那一刻差最多(8 秒:網頁已經寫「黎明」,遊戲還在凌晨)。
708	 *
709	 *   `scale = day_len / day_nominal`,兩個值都由 MUD 給,網站端不知道 1200 是什麼。
710	 *   ⚠ `day_nominal` 是後來才加的欄位:**沒有它就 scale=1**,退回未校正的行為 ——
711	 *     所以網站可以先上、MUD 後上,部署順序不敏感。
712	 *   ⚠ 被 clamp 的日子(重啟/嚴重 lag)day_len 會是名目值 → scale 剛好 1,自然不校正。
713	 */
714	var dayScale = function(world) {
715		var nom = world.day_nominal;
716		if (!(nom > 0) || !(world.day_len > 0)) return 1;
717		return world.day_len / nom;
718	};
719
720	var phases = function(world) {
721		var len = world.day_len;
722		var k = dayScale(world);
723		return world.phases.map(function(row, i, all) {
724			var start = row[0] * k;
725			var end = (i + 1 < all.length) ? all[i + 1][0] * k : len; // 最後一段延伸到 day_len
726			return {
727				id: row[1],
728				cn: CN_PHASE[row[1]] || row[1],
729				start: start,
730				end: end,
731				center: (start + end) / 2
732			};
733		});
734	};
735
736	// ⚠ 回傳 index 而不是物件:phases() 每次都新建陣列,拿物件回去 indexOf 一定找不到
737	//    (回傳 -1,(-1+1)%8 = 0 → 「下一個時段」永遠變成第一個。踩過。)
738	var idxAt = function(ps, elapsed) {
739		var i = 0;
740		for (var k = 0; k < ps.length; k++)
741			if (elapsed >= ps[k].start) i = k;
742		return i;
743	};
744
745	/* ⚠ elapsed 一律先取模再用。
746	   `elapsed` 是「距離 day_start 幾秒」,而 day_start 只有換日時才重寫 ——
747	   所以在「遊戲已經換日、但網站還沒拿到新的 world.json」那段空窗,elapsed 會超過 day_len。
748	   不取模的話 idxAt 會一直停在最後一段(凌晨),`p.end - elapsed` 變成負數被夾成 0,
749	   **倒數就凍住不動**(2026-08-22 使用者實測回報:跨日之後秒數不再變)。
750	   frameAt 本來就有取模所以日晷照轉,兩邊不一致才是這個 bug 的根因 —— 一律走這個 wrap。 */
751	var wrap = function(world, elapsed) {
752		var len = world.day_len;
753		return ((elapsed % len) + len) % len;
754	};
755
756	var phaseAt = function(world, elapsed) {
757		var ps = phases(world);
758		return ps[idxAt(ps, wrap(world, elapsed))];
759	};
760
761	/* ---- 螢火蟲的出沒判斷(強度表在 §二 的 FIREFLY_CFG,那裡有完整說明) ----
762	   放在這裡是因為它要用 phaseAt/wrap —— 三個條件同時成立才出現:
763	   時段(夜晚整段 ~ 凌晨前半)、天氣平靜(晴朗/陰天/微風)、非冰河期;隻數再看季節。
764
765	   ⚠ 時段一律用 phaseAt 的 **id** 判,不要拿 frameAt 的亮度曲線去猜 —— 與 moonDayNight
766	     同一個理由:那條曲線的交界點跟「時段本身叫什麼名字」對不上。
767	   ⚠ 凌晨的一半用**那個時段自己的**起訖算,不是寫死秒數:各段長度本來就不一樣,
768	     而且 phases() 會依 day_len 校正等比縮放(§3-5-2),寫死的秒數會慢慢漂掉。 */
769	var fireflyOn = function(world, elapsed) {
770		if (!world || world.ice_age) return false;
771		if (FIREFLY_CALM.indexOf(world.weather) < 0) return false;
772		var p = phaseAt(world, elapsed);
773		if (p.id === 'night') return true;
774		if (p.id !== 'midnight') return false;
775		return (wrap(world, elapsed) - p.start) < (p.end - p.start) * FIREFLY_CUT;
776	};
777
778	// 這一夜該灑幾隻:夏季 FIREFLY_N,其餘季節乘上 FIREFLY_SEASON 的倍率。
779	// ⚠ 值域外的季節退回春秋那一級 —— 不能退回 0,那會讓效果**靜默消失**;也不該退回最大值。
780	var fireflyCount = function(world) {
781		var k = (world && (world.season in FIREFLY_SEASON)) ? FIREFLY_SEASON[world.season] : FIREFLY_SEASON.spring;
782		return Math.round(FIREFLY_N * k);
783	};
784
785	/* 給呼叫端判斷「這一層要不要重建」用的鍵:不出現時是空字串,出現時是季節
786	   —— 隻數只跟季節有關,所以三種平靜天氣之間互換**不必**重灑一批(鍵沒變就別動它,
787	   重建會讓所有螢火蟲的閃爍相位歸零,看起來像整群同時眨一下眼)。 */
788	var fireflyKey = function(world, elapsed) {
789		return fireflyOn(world, elapsed) ? (world.season || '?') : '';
790	};
791
792	// 這一刻該有的螢火蟲 HTML;不該出現就回空字串(呼叫端決定要不要連容器一起拆掉)
793	var fireflyHtml = function(world, elapsed) {
794		if (!fireflyOn(world, elapsed)) return '';
795		var cfg = {};
796		Object.keys(FIREFLY_CFG).forEach(function(k) {
797			cfg[k] = FIREFLY_CFG[k];
798		});
799		cfg.n = fireflyCount(world);
800		return layerHtml(cfg);
801	};
802
803	/* 日晷的插值。
804	 *
805	 * 錨點在每個時段的**正中間**,而且 u 是用「半段進度」正規化的:
806	 * 後半段 center→end 對應 u 0→0.5、前半段 start→center 對應 u 0.5→1。
807	 *
808	 * 為什麼不用兩個中心點之間的絕對秒數:八個時段長度不一樣(90/140/140/140/150/150/210/180),
809	 * 用絕對秒數的話時段交界不會落在 0.5 上 —— 黎明只有 90 秒、日出有 140 秒,交界會偏在 39%,
810	 * 於是「剛進入日出」的畫面反而更接近黎明,與旁邊的文字說法打架。
811	 * 正規化後有一個乾淨的性質:**u < 0.5 ⟺ 還在這個時段**,交界剛好兩張各半。
812	 */
813	var frameAt = function(world, elapsed) {
814		var ps = phases(world),
815			n = ps.length,
816			len = world.day_len;
817		var t = ((elapsed % len) + len) % len;
818		var i = 0;
819		for (var k = 0; k < n; k++)
820			if (t >= ps[k].start) i = k;
821		var p = ps[i],
822			a, b, u;
823		if (t >= p.center) {
824			a = SUNDIAL[p.id];
825			b = SUNDIAL[ps[(i + 1) % n].id];
826			u = 0.5 * (t - p.center) / (p.end - p.center);
827		} else {
828			a = SUNDIAL[ps[(i - 1 + n) % n].id];
829			b = SUNDIAL[p.id];
830			u = 0.5 + 0.5 * (t - p.start) / (p.center - p.start);
831		}
832		var f = {};
833		FIELDS.forEach(function(q) {
834			f[q] = lerp(fieldVal(a[q], world.weather), fieldVal(b[q], world.weather), u);
835		});
836		f._w = (t >= p.center) ? (1 - u) : u; // 目前這個時段佔多少(恆 >= 0.5,正中間 = 1)
837		f._i = i;
838		return f;
839	};
840
841	// 影子的 inline style。scaleY 在正午前後會通過 0 —— 那正好是太陽在正上方、
842	// 影子縮到沒有再翻到另一邊的瞬間,不是 bug。
843	// ⚠ |scaleY| < 0.91 時直接 display:none —— 縮到快 0 那段用 CSS 硬拉伸看起來像被壓扁的假影子,
844	// 不如乾脆藏起來(反正 opacity/geometry 仍照算,只是不畫)ã€
844‚
845	var shadowCss = function(f) {
846		var base = 'width:' + f.w.toFixed(2) + '%;height:' + f.h.toFixed(2) + '%;' +
847			'left:' + f.l.toFixed(2) + '%;top:' + f.t.toFixed(2) + '%;opacity:' + f.o.toFixed(3) + ';' +
848			'transform:scaleY(' + f.sy.toFixed(4) + ') skew(' + f.kx.toFixed(2) + 'deg,' + f.ky.toFixed(2) + 'deg)';
849		if (Math.abs(f.sy) < 0.91) base += ';display:none';
850		return base;
851	};
852
853	var dialCss = function(f) {
854		return 'filter:hue-rotate(' + f.hue.toFixed(2) + 'deg) brightness(' + f.br.toFixed(3) + ') saturate(' + f.sat.toFixed(2) + ')';
855	};
856
857	// .ws-pin-highlight(晷針受光面)自己的一層 filter,只調 brightness ——
858	// 與 dial 的 br 分開存(pbr)是因為兩層要能各自調,不是同一塊面板共用一個亮度。
859	var pinHighlightCss = function(f) {
860		return 'filter:brightness(' + f.pbr.toFixed(3) + ')';
861	};
862
863	// .ws-grass 自己的一層 filter,調 saturate 跟 hue-rotate ——跟 dial 的 sat/hue 分開存
864	// (gsat/ghue)是因為 .ws-grass 是 #ws-dial 的子元素,dial 的 filter 本來就會疊加到它
865	// 身上,這兩個是額外再加的一層,數字不用跟 dial 對齊(晚上/傍晚草的飽和度降到 0.8,
866	// 其餘 1;ghue 是天氣決定的,不是時段——高溫天草轉 -40deg 偏黃(枯萎感),其餘天氣 0)。
867	var grassCss = function(f) {
868		return 'filter:saturate(' + f.gsat.toFixed(2) + ') hue-rotate(' + f.ghue.toFixed(2) + 'deg)';
869	};
870
871	// 月亮 badge 的亮度:白天黯淡、夜裡發亮,取自日晷同一條曲線 —— 調 SUNDIAL 的 br,
872	// 月亮自動跟著改,不必在別處再寫一組數字。
873	// ⚠ br 可能是 { 天氣: 數值 } 物件(§noon)—— min/max 要展開所有天氣的值一起算,
874	//   否則月亮亮度曲線只看得到 default 那一個數字,雨天/颱風天算出來的 f.br 反而會超出 [min,max]。
875	var BR = (function() {
876		var v = [];
877		Object.keys(SUNDIAL).forEach(function(k) {
878			var br = SUNDIAL[k].br;
879			if (br !== null && typeof br === 'object') {
880				Object.keys(br).forEach(function(wk) {
881					v.push(br[wk]);
882				});
883			} else {
884				v.push(br);
885			}
886		});
887		return {
888			min: Math.min.apply(null, v),
889			max: Math.max.apply(null, v)
890		};
891	})();
892	var moonGlow = function(f) {
893		return (BR.max === BR.min) ? 0 : (BR.max - f.br) / (BR.max - BR.min);
894	};
895
896	// 月相 badge 的顯色:不連續漸變,固定「白天」「夜晚」兩態(class 見 library.css)——
897	// 直接照時段分:只有 night/midnight 算夜晚,其餘六個時段(含黎明/日出/傍晚)一律白天。
898	// ⚠ 不能用 phaseAt 內部混合的 f._w/br 去判斷 —— 那條曲線是給亮度漸變用的,交界點
899	//   跟「時段本身叫什麼名字」對不上(dawn/sunrise/evening 的 br 夠低,亮度曲線會把它們
900	//   算成夜晚),兩者概念不同,要分開算。
901	var moonDayNight = function(world, elapsed) {
902		var id = phaseAt(world, elapsed).id;
903		return (id === 'night' || id === 'midnight') ? 'is-night' : 'is-day';
904	};
905
906	// 「現在算不算夜裡」的**唯一一份**判準:月相 badge 的日夜二態、天氣層的 nightScale 都走這支。
907	// 又寫一次 id === 'night' || 'midnight' 就是漂移的起點(哪天多一個時段,只會有一邊被改到)。
908	var isNightPhase = function(world, elapsed) {
909		return moonDayNight(world, elapsed) === 'is-night';
910	};
911
912	/* ================= 五、文字 ================= */
913
914	var dateLine = function(world, elapsed) {
915		var c = world.calendar || {};
916		var ps = phases(world);
917		var t = wrap(world, elapsed); // ⚠ 跨日之後不取模的話倒數會凍住,見 wrap 的說明
918		var i = idxAt(ps, t);
919		var p = ps[i];
920		var nx = ps[(i + 1) % ps.length];
921		return {
922			age: '原始世紀 第' + (CN_NUM[c.age] || c.age) + '紀元',
923			date: c.year + ' 年 ' + c.month + ' 月 ' + c.day + ' 日',
924			season: CN_SEASON[world.season] || world.season,
925			phase: p.cn,
926			// ⚠ 前端只從這個秒數往下減,**不可以**拿 Date.now() 去減 day_start ——
927			//   使用者的時鐘偏移會讓時段靜默錯位(§2)。
928			left: Math.max(0, p.end - t),
929			count: '再過 ' + fmtDur(p.end - t) + '就進入' + nx.cn
930		};
931	};
932
933	/* badge 清單。回傳值而不是 HTML —— SSR 用它產生標記、前端用它更新 DOM,同一份判斷。
934	   text 允許帶 <b>(房名要強調),其餘一律先 esc 過。 */
935	var badges = function(world, starfall, elapsed) {
936		var out = [];
937
938		out.push({
939			kind: 'weather',
940			show: true,
941			weather: world.weather, // badgeHtml 用它掛 data-weather,CSS 依此選圖(FACE_WEATHER 現在全空)
942			face: FACE_WEATHER[world.weather] || '',
943			label: '天氣:' + (CN_WEATHER[world.weather] || world.weather), // badgeHtml 用它掛 aria-label
944			text: '今天天氣:' + esc(CN_WEATHER[world.weather] || world.weather)
945		});
946
947		// 白天時月相還沒「開獎」,文案只講會是什麼、不裝作看得到;入夜後(moonDayNight)才改口
948		// 成現行的直述句。判準與 badge 圖面的日夜二態共用同一個 moonDayNight,不要另開一套。
949		out.push({
950			kind: 'moon',
951			show: true,
952			moon: world.moon, // badgeHtml 用它掛 data-moon,CSS 依此選圖(FACE_MOON 現在全空)
953			face: FACE_MOON[world.moon] || '',
954			label: '月相:' + (CN_MOON[world.moon] || world.moon), // badgeHtml 用它掛 aria-label
955			text: '今夜月相:' + esc(CN_MOON[world.moon] || world.moon) +
956				(moonDayNight(world, elapsed) === 'is-night' ? '' : ',入夜後顯現')
957		});
958
959		/* 流星。⚠ 只給公告裡的房間名字 —— room path、座標、區域一律不給(§5-4 紅線):
960		   走遍整個區域去找才是這個玩法本身。
961		   ⚠ 先看 expire_at 再看 state:磁碟上那份可能是上一顆的殘骸,而 claimed 的補寫
962		     在 MUD 重開時會遺失(§5-5)。expired 只是變暗,badge 還在 —— 那顆星宮今天仍當值。 */
963		if (starfall) {
964			var zodiac = esc(CN_PALACE[starfall.palace] || starfall.palace) + '座';
965			var pal = '最近一次的流星出現在<b>' + zodiac + '</b>,';
966			var st = starfall.expired ? 'expired' : (starfall.state === 'claimed' ? 'claimed' : 'fallen');
967			out.push({
968				kind: 'starfall',
969				show: true,
970				state: st,
971				palace: starfall.palace, // badgeHtml 用它掛 data-palace,CSS 依此選星座圖(FACE_PALACE 現在全空)
972				face: FACE_PALACE[starfall.palace] || '',
973				label: '流星:' + zodiac, // badgeHtml 用它掛 aria-label
974				text: st === 'fallen' ?
975					'來自' + zodiac + '的流星墜落在<b>' + esc(starfall.room_name) + '</b>附近,星星碎片還有 ' + fmtDur(starfall.remain) + ' 後消逝。' :
976					(st === 'claimed' ? pal + '流星已經出現,星星碎片已被發現。' : pal + '流星已經消逝,星星碎片無人發現。')
977			});
978		} else if (world.palace) {
979			/* 第四態 pending:今天還沒掉過流星。
980			   星宮是**現實日**的常數(MUD 端 get_yearday() % 12),半夜零點就已經定了 ——
981			   但它以前只寫在事件驅動的 starfall.json 裡,所以流星沒掉就整顆 badge 不出現。
982			   改成 world.json 也帶一份(每次換日重寫)之後,這裡才有東西可以先講。
983			   ⚠ 仍然**不推算**:值直接讀 world.palace,網站端自己算 yearday 就是 §3-3 的漂移坑。
984			   ⚠ 只講星宮、不講地點 —— 流星還沒掉,本來就沒有 room_name 可講(§5-4 紅線照舊)。 */
985			out.push({
986				kind: 'starfall',
987				show: true,
988				state: 'pending',
989				palace: world.palace,
990				face: FACE_PALACE[world.palace] || '',
991				label: '流星:' + (CN_PALACE[world.palace] || world.palace) + '座', // badgeHtml 用它掛 aria-label
992				text: '下一次流星會出現在<b>' + esc(CN_PALACE[world.palace] || world.palace) + '座</b> —— 今天的流星還未出現。'
993			});
994		}
995
996		// 彩虹:MUD 給的是邊界,網站端只做區間比對(§3-4)。窗口外整顆不出現。
997		var rb = world.rainbow;
998		if (rb && elapsed >= rb.from && elapsed < rb.to) {
999			out.push({
1000				kind: 'rainbow',
1001				show: true,
1002				rare: true,
1003				face: '', // 已換像素圖,CSS 依 data-kind=rainbow 選圖
1004				label: '彩虹', // badgeHtml 用它掛 aria-label
1005				text: '雨後放晴,天邊掛著一道彩虹 —— 還看得見 ' + fmtDur(rb.to - elapsed) + '。'
1006			});
1007		}
1008
1009		if (world.ice_age) {
1010			out.push({
1011				kind: 'ice',
1012				show: true,
1013				rare: true,
1014				face: '', // 已換像素圖,CSS 依 data-kind=ice 選圖(/image/sundial/ice_age.png)
1015				label: '冰河期', // badgeHtml 用它掛 aria-label
1016				text: '冰河期籠罩大地,這段日子只會有下雪、寒流與陰天。'
1017			});
1018		}
1019
1020		// 火山:natured.c 的「火山活躍期」(每週四,3 月除外)或限時任務「起床氣」任一成立就是 true——
1021		// 兩個事件都會觸發 start_volcano_war() 在特定整點開戰,但確切時辰不寫進這句話,
1022		// 只講「不太平靜」這個玩家看得到、不會錯的結論(§5-4 同一條紅線:忠實轉述,不推算細節)。
1023		if (world.volcano) {
1024			out.push({
1025				kind: 'volcano',
1026				show: true,
1027				rare: true,
1028				face: '', // 已換像素圖,CSS 依 data-kind=volcano 選圖(/image/sundial/volcano.png)
1029				label: '火山活躍期', // badgeHtml 用它掛 aria-label
1030				text: '亞姆亞姆火山正值活躍期,隨時注意火山警報。'
1031			});
1032		}
1033
1034		return out;
1035	};
1036
1037	/* ================= 六、SSR 的標記 =================
1038	   ⚠ 值由後端填好再送出,**不要 SSR 出「載入中…」**(會被爬蟲抓走)ã€
1038‚
1039	   ⚠ 抓不到資料時骨架要留著、數值換成「暫時無法取得」,否則版位會跳(§7)——
1040	     這一點與首頁 {{updates}}「讀不到就整段不出」的慣例**相反**,別順手改成一致。
1041	     MUD 每次重開機後的前約 18 分鐘就是這個狀態。 */
1042	var UNAVAILABLE = '暫時無法取得';
1043
1044	var badgeHtml = function(b) {
1045		return '<li class="ws-badge' + (b.rare ? ' is-rare' : '') + '" data-kind="' + b.kind + '"' +
1046			(b.state ? ' data-state="' + b.state + '"' : '') +
1047			(b.weather ? ' data-weather="' + b.weather + '"' : '') +
1048			(b.palace ? ' data-palace="' + b.palace + '"' : '') +
1049			(b.moon ? ' data-moon="' + b.moon + '"' : '') + '>' +
1050			'<button type="button" class="ws-badge-btn" aria-label="' + esc(b.label || b.kind) + '" aria-describedby="ws-pop-' + b.kind + '" aria-expanded="false">' +
1051			'<span class="ws-face" id="ws-face-' + b.kind + '">' + b.face + '</span>' +
1052			'</button>' +
1053			'<span class="ws-pop" id="ws-pop-' + b.kind + '" role="tooltip">' + b.text + '</span>' +
1054			'</li>';
1055	};
1056
1057	var renderHtml = function(st) {
1058		var ok = !!(st && st.ok);
1059		var world = ok ? st.world : null;
1060		// 讀不到資料時沒有 world.weather 可查,fieldVal 的第二個參數傳 undefined
1061		// 自然落到每個欄位的 default(物件欄位一定要有 default,否則這裡會是 NaN)。
1062		var f = ok ? frameAt(world, st.elapsed) : (function() {
1063			var r = {};
1064			FIELDS.forEach(function(q) {
1065				r[q] = fieldVal(SUNDIAL.noon[q], undefined);
1066			});
1067			return r;
1068		})();
1069		var line = ok ? dateLine(world, st.elapsed) : {
1070			age: '原始世紀',
1071			date: UNAVAILABLE,
1072			season: '',
1073			phase: '',
1074			count: ''
1075		};
1076		var bs = ok ? badges(world, st.starfall, st.elapsed) : [];
1077		var glow = moonGlow(f);
1078		var moonDN = ok ? moonDayNight(world, st.elapsed) : '';
1079
1080		// 前端要重算,得知道 world 與 elapsed —— ⚠ `</` 要跳脫,這份 JSON 直接寫進 <script>。
1081		var data = ok ? {
1082			world: world,
1083			starfall: st.starfall || null,
1084			elapsed: st.elapsed,
1085			// 產生這份 HTML 的伺服器時刻:前端拿它當倒數的起點,不用自己的時鐘去減 day_start
1086			served_at: st.served_at || 0
1087		} : null;
1088
1089		return '' +
1090			'<section class="worldstate' + (ok ? '' : ' is-blank') + '" id="worldstate"' +
1091			' aria-labelledby="ws-heading">\n' +
1092			'    <h2 class="cs-heading fontsize2" id="ws-heading">☘ 此刻的原始物語 ☘</h2>\n' +
1093			'    <div class="ws-block">\n' +
1094			// 天氣 class 掛在**外層**:.ws-dial 的 filter 是插值寫進去的 inline style,
1095			// 效果層若也用 filter,寫在 dial 上會被蓋掉;掛外層兩層 filter 會自然疊加。
1096			'        <div class="ws-stage ' + (ok ? 'wx-' + world.weather + (world.ice_age ? ' is-ice' : '') : '') + '" id="ws-stage">\n' +
1097			'            <div class="ws-dial" id="ws-dial" style="' + dialCss(f) + '" aria-hidden="true">\n' +
1098			'                <div class="ws-grass" id="ws-grass" style="' + grassCss(f) + '"></div>\n' +
1099			'                <div class="ws-pin"></div>\n' +
1100			'                <div class="ws-pin-highlight" id="ws-pin-highlight" style="' + pinHighlightCss(f) + '"></div>\n' +
1101			'                <div class="ws-shadow" id="ws-shadow" style="' + shadowCss(f) + '"></div>\n' +
1102			'                <div class="ws-body"></div>\n' +
1103			'                <div class="ws-cloud-shadow"></div>\n' +
1104			'            </div>\n' +
1105			'        </div>\n' +
1106			'        <div class="ws-text">\n' +
1107			'            <h3 class="ws-date"><span class="ws-age" id="ws-age">' + esc(line.age) + '</span><br>' +
1108			'<span id="ws-date">' + esc(line.date) + '</span>' +
1109			(line.season ? '<span id="ws-season"> (' + esc(line.season) + ')</span>' : ' <span id="ws-season"></span>') +
1110			(line.phase ? ' <span id="ws-phase">' + esc(line.phase) + '</span>' : ' <span id="ws-phase"></span>') +
1111			'</h3>\n' +
1112			'            <p class="ws-next" id="ws-count">' + esc(line.count) + '</p>\n' +
1113			'            <ul class="ws-badges' + (moonDN ? ' ' + moonDN : '') + '" id="ws-badges" style="--moon:' + glow.toFixed(3) + '">' +
1114			bs.map(badgeHtml).join('') + '</ul>\n' +
1115			'            <p class="ws-foot">這是馬雅城天文台的日晷,實時反映了原始物語世界中的時間和天象。' +
1116			'原始物語的一年有 12 個月、一個月固定 30 天,遊戲裡的一天大約是現實世界的 20 分鐘。<br>
1116' +
1117			' <a href="/help/calendar">看曆法說明 ▸</a>' +
1118			// 火山 badge 亮起時多一句連到專屬說明頁——/help/calendar 沒提火山,
1119			// 那篇規則(確切開戰時辰等)完全在 /help/volcano_war 裡,平常連結蓋不到。
1120			(ok && world.volcano ? ' | <a href="/help/volcano_war">看火山起床氣說明 ▸</a>' : '') +
1121			(ok && st.updated_iso ? '<br><span class="ws-upd">更新於 <time datetime="' + esc(st.updated_iso) + '">' +
1122				esc(st.updated_text || '') + '</time></span>' : '') +
1123			'</p>\n' +
1124			'        </div>\n' +
1125			'    </div>\n' +
1126			(data ? '    <script>window.PS_WORLD_DATA = ' +
1127				JSON.stringify(data).replace(/<\//g, '<\\/') + ';<\/script>\n' : '') +
1128			'</section>';
1129	};
1130
1131	return {
1132		SUNDIAL: SUNDIAL,
1133		CN_PHASE: CN_PHASE,
1134		CN_WEATHER: CN_WEATHER,
1135		CN_MOON: CN_MOON,
1136		CN_SEASON: CN_SEASON,
1137		CN_PALACE: CN_PALACE,
1138		CN_NUM: CN_NUM,
1139		FACE_WEATHER: FACE_WEATHER,
1140		FACE_MOON: FACE_MOON,
1141		FACE_PALACE: FACE_PALACE,
1142		UNAVAILABLE: UNAVAILABLE,
1143		esc: esc,
1144		fmtDur: fmtDur,
1145		PARTICLE_CFG: PARTICLE_CFG,
1146		weatherParticlesHtml: weatherParticlesHtml,
1147		weatherKey: weatherKey,
1148		isNightPhase: isNightPhase,
1149		FIREFLY_CFG: FIREFLY_CFG,
1150		FIREFLY_SEASON: FIREFLY_SEASON,
1151		FIREFLY_CALM: FIREFLY_CALM,
1152		FIREFLY_CUT: FIREFLY_CUT,
1153		fireflyOn: fireflyOn,
1154		fireflyCount: fireflyCount,
1155		fireflyKey: fireflyKey,
1156		fireflyHtml: fireflyHtml,
1157		dayScale: dayScale,
1158		fieldVal: fieldVal,
1159		phases: phases,
1160		phaseAt: phaseAt,
1161		frameAt: frameAt,
1162		shadowCss: shadowCss,
1163		dialCss: dialCss,
1164		pinHighlightCss: pinHighlightCss,
1165		grassCss: grassCss,
1166		moonGlow: moonGlow,
1167		moonDayNight: moonDayNight,
1168		dateLine: dateLine,
1169		badges: badges,
1170		badgeHtml: badgeHtml,
1171		renderHtml: renderHtml
1172	};
1173});

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.