diff --git a/docs/games/index.html b/docs/games/index.html
index 2dc2a39..2835459 100644
--- a/docs/games/index.html
+++ b/docs/games/index.html
@@ -105,9 +105,10 @@
/* 任務按鈕 + 清單 */
#topright { position:fixed; right:16px; top:16px; z-index:12; display:flex; gap:8px; align-items:flex-start; }
- #logbtn, #soundbtn, #achbtn, #shotbtn, #explorebadge { cursor:pointer; border:0; font:inherit; font-size:13px; font-weight:700; color:var(--ink);
+ #logbtn, #soundbtn, #achbtn, #shotbtn, #fpvbtn, #explorebadge { cursor:pointer; border:0; font:inherit; font-size:13px; font-weight:700; color:var(--ink);
background:rgba(251,247,239,.93); padding:9px 14px; border-radius:999px; box-shadow:var(--shadow); }
- #soundbtn, #shotbtn { padding:9px 12px; }
+ #soundbtn, #shotbtn, #fpvbtn { padding:9px 12px; }
+ #fpvbtn.on { color:#fff; background:rgba(58,86,122,.95); } /* 第一人稱進行中:反白,避免只靠圖示分不出目前模式 */
#explorebadge { color:#8a6d1f; background:rgba(255,224,130,.95); } /* 探索模式標籤:日光金;點一下回到故事模式 */
/* 語言切換器(由 main.js 動態插入,少於 2 種語言時不顯示) */
#langsw { display:flex; gap:3px; background:rgba(251,247,239,.93); padding:4px; border-radius:999px; box-shadow:var(--shadow); }
@@ -577,6 +578,7 @@
資安防護新手村 · 探險
+
diff --git a/docs/games/main.js b/docs/games/main.js
index 81f1ffc..0689c0d 100644
--- a/docs/games/main.js
+++ b/docs/games/main.js
@@ -232,11 +232,25 @@ const WATER_NIGHT = new THREE.Color(0x1f3a52), WATER_DAY = new THREE.Color(0x4f9
// 桌機:大氣散射天空(Sky addon)+夜空星點;手機:維持原漸層天空球(零變動)
let atmoSky = null, stars = null;
+const SKY_KNEE = 1.0, SKY_CEIL = 1.8; // 天空進 bloom 前的軟上限(見下方說明);1.8/2.5/3.2/5.0 都量過,1.8 是地面完全不過曝的最高值
const _skySun = new THREE.Vector3();
if (Q.richSky) {
atmoSky = new Sky(); atmoSky.scale.setScalar(1200); // 控制在鏡頭 far(1200) 內、又比場景大 → 永遠當背景
const su = atmoSky.material.uniforms;
su.turbidity.value = 6; su.rayleigh.value = 2.2; su.mieCoefficient.value = 0.005; su.mieDirectionalG.value = 0.8;
+ // 天空亮度軟上限。Sky 輸出的是物理輻射值(白天實測 12–25),而它的 在離屏
+ // render target 上是 no-op(three.js 對 render target 一律關 tone mapping,留給最後的 OutputPass)→
+ // 整片天空以 12–25 的量進入 bloom(threshold 才 1.05),再被 radius 0.4 的大範圍糊開。
+ // 第三人稱天空只佔畫面上緣,糊下來還在可接受範圍;第一人稱地平線落在畫面中央,就把整個下半部洗白。
+ // 這裡在天空自己的 shader 收斂:SKY_KNEE 以下原封不動(雲、日出日落的層次全留著),之上漸近逼近 SKY_CEIL。
+ // 只動天空 → 水晶/火把/法師球等 emissive 的 bloom 完全不受影響(選擇性 bloom 雙 composer 已知會在部分裝置讓發光物消失,不走那條)。
+ su.uSkyCeil = { value: SKY_CEIL };
+ atmoSky.material.fragmentShader = 'uniform float uSkyCeil;\n' + atmoSky.material.fragmentShader.replace(
+ 'gl_FragColor = vec4( texColor, 1.0 );',
+ `{ float m = max(max(texColor.r, texColor.g), texColor.b); // 取最亮通道 → 等比縮放、不偏色
+ if (m > ${SKY_KNEE.toFixed(1)}) texColor *= (${SKY_KNEE.toFixed(1)} + (m - ${SKY_KNEE.toFixed(1)}) / (1.0 + (m - ${SKY_KNEE.toFixed(1)}) / max(1e-3, uSkyCeil - ${SKY_KNEE.toFixed(1)}))) / m; }
+ gl_FragColor = vec4( texColor, 1.0 );`);
+ atmoSky.material.needsUpdate = true;
if ('cloudCoverage' in su) { su.cloudCoverage.value = 0.35; su.cloudDensity.value = 0.35; su.cloudSpeed.value = 0.00006; } // r184 內建微雲
scene.add(atmoSky);
// 夜空星點:上半球隨機分布,opacity 由 (1-vibrancy) 驅動 → 夜晚顯現、白天淡出
@@ -2294,6 +2308,20 @@ const keys = new Set();
addEventListener('keydown', (e) => { if (e.code === 'Space') e.preventDefault(); keys.add(e.code); bumpActive(); });
addEventListener('keyup', (e) => { keys.delete(e.code); });
let yaw = Math.PI, pitch = 0.6, dist = 24;
+// ── 第一/第三人稱:兩模式共用同一組 yaw/pitch ─────────────────────
+// pitch 在第三人稱是「相機比主角高多少」、在第一人稱是「視線俯角」,但方向一致(越大越往下看),
+// 所以兩邊拖曳手感相同、切換時畫面不會上下顛倒;差別只在可用範圍:第三人稱不能鑽到地面下,第一人稱要能抬頭看天。
+let fpv = false;
+const FP_EYE = 2.6; // 視點高度=頭部中心(hero 局部 y);主角約 3m 高
+// 視點往視線前方推 0.45:相機原本剛好架在自己軀幹(膠囊 r=0.55)的正上方,稍微低頭就有一大塊橘色身體糊在畫面下緣。
+// 推到身體之前就看不到了,身體本身仍留著投影(低頭雖看不到腳,但陽光下自己的影子還在)。
+// 上限受碰撞半徑約束:HERO_R=0.6 讓身體停在牆前 0.6,相機推 0.45 後離牆面還有 0.15 > near plane 0.1 → 貼牆也不會穿進室內。
+const FP_FWD = 0.45;
+const FP_FOV = 68, TP_FOV = camera.fov; // 第三人稱 52° 用在第一人稱會像望遠鏡;FPS 慣例 65–75°
+// 抬頭到 +1.35(77°,留餘裕避開 lookAt 在正上方的 up 向量退化),低頭只到 1.0(57°):
+// 相機往前推的 0.45 小於軀幹半徑 0.55,再往下看就會拍到自己那塊沒有細節的膠囊——1.0 已足夠看清腳前的地面。
+const FP_PITCH_UP = 1.35, FP_PITCH_DN = 1.0;
+const clampPitch = (p) => (fpv ? THREE.MathUtils.clamp(p, -FP_PITCH_UP, FP_PITCH_DN) : THREE.MathUtils.clamp(p, 0.16, 1.2));
// 開場就把鏡頭擺到跟隨位置,避免從原點 (0,0,0) 起始=卡在中央水晶光束裡(遺跡全通後光束很亮會洗版)
{ const sy = groundY(hero.position.x, hero.position.z); camera.position.set(hero.position.x + Math.sin(yaw) * Math.cos(pitch) * dist, sy + Math.sin(pitch) * dist + 2, hero.position.z + Math.cos(yaw) * Math.cos(pitch) * dist); }
const moveTarget = new THREE.Vector3(); let hasTarget = false;
@@ -2305,11 +2333,11 @@ dom.addEventListener('pointermove', (e) => {
if (!pDown) return;
const dx = e.clientX - lastX, dy = e.clientY - lastY;
if (!dragging && Math.hypot(e.clientX - downX, e.clientY - downY) > 6) dragging = true;
- if (dragging) { yaw -= dx * 0.005; pitch = THREE.MathUtils.clamp(pitch - dy * 0.004, 0.16, 1.2); bumpActive(); }
+ if (dragging) { yaw -= dx * 0.005; pitch = clampPitch(pitch - dy * 0.004); bumpActive(); }
lastX = e.clientX; lastY = e.clientY;
});
dom.addEventListener('pointerup', (e) => {
- if (pDown && !dragging) {
+ if (pDown && !dragging && !fpv) { // 第一人稱不做點地移動:盯著地面點一下然後自己走過去,與「視線=面向」的語意衝突(手機仍有搖桿)
ndc.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1);
raycaster.setFromCamera(ndc, camera);
const hit = raycaster.intersectObject(terrain, false)[0];
@@ -2322,7 +2350,20 @@ dom.addEventListener('pointerup', (e) => {
}
pDown = false; dragging = false;
});
-addEventListener('wheel', (e) => { dist = THREE.MathUtils.clamp(dist + e.deltaY * 0.025, 14, 56); bumpActive(); }, { passive: true });
+addEventListener('wheel', (e) => { if (fpv) return; dist = THREE.MathUtils.clamp(dist + e.deltaY * 0.025, 14, 56); bumpActive(); }, { passive: true });
+// 視角切換:V 鍵或右上角 👁。dist 保持不動 → 切回第三人稱時鏡頭距離跟切走前一樣。
+const fpvBtn = document.getElementById('fpvbtn');
+function setFPV(on) {
+ if (on === fpv) return;
+ fpv = on;
+ pitch = clampPitch(pitch); // 第一人稱→第三人稱時把抬頭的視角壓回地面以上
+ camera.fov = fpv ? FP_FOV : TP_FOV; camera.updateProjectionMatrix();
+ heroBubble.visible = !fpv; // 泡泡掛在頭頂 y=3.6=第一人稱相機正上方 1m,CSS2D 投影會跑到畫面外
+ hasTarget = false; // 取消進行中的點地移動
+ fpvBtn.classList.toggle('on', fpv); fpvBtn.setAttribute('aria-pressed', fpv ? 'true' : 'false');
+ bumpActive();
+}
+fpvBtn.addEventListener('click', () => setFPV(!fpv));
// 首次互動解鎖音訊(瀏覽器自動播放限制;iOS 需多種手勢)
['pointerdown', 'touchend', 'click', 'keydown'].forEach((ev) => addEventListener(ev, () => SFX.unlock(), { passive: true }));
document.addEventListener('visibilitychange', () => { if (!document.hidden) SFX.unlock(); });
@@ -3261,6 +3302,7 @@ const torchParams = [
let torchSel = 0;
addEventListener('keydown', (e) => {
if (document.activeElement && document.activeElement.tagName === 'INPUT') return;
+ if (e.code === 'KeyV') { setFPV(!fpv); return; }
if (e.code === 'KeyG') { coordsEl.classList.toggle('show'); return; }
if (!coordsEl.classList.contains('show')) return;
if (e.code === 'BracketLeft') torchSel = (torchSel + torchParams.length - 1) % torchParams.length;
@@ -3316,7 +3358,7 @@ function animate(time) {
if (egg.active) { labelRenderer.domElement.style.display = 'none'; egg.update(dt); return; } // 彩蛋小遊戲:凍結 3D 世界,僅跑 2D 覆蓋層(不透明、免 renderScene)
if (fishing.active) { labelRenderer.domElement.style.display = 'none'; fishing.update(dt); return; } // 釣魚小遊戲:同彩蛋,凍結 3D 世界
if (stations.active) { labelRenderer.domElement.style.display = 'none'; stations.update(dt); return; } // 支線站台:同上,凍結 3D 世界,僅跑 DOM 覆蓋層
- if (battle.active) { labelRenderer.domElement.style.display = 'none'; gradePass.uniforms.uVibrancy.value = 1; setWorldLight(1); battle.update(dt); renderScene(); return; }
+ if (battle.active) { labelRenderer.domElement.style.display = 'none'; head.visible = true; gradePass.uniforms.uVibrancy.value = 1; setWorldLight(1); battle.update(dt); renderScene(); return; } // 戰鬥自己接管相機(第三人稱運鏡)且提早 return 跳過下面的鏡頭段 → 頭要在這裡裝回去,否則第一人稱切進戰鬥會是無頭主角
labelRenderer.domElement.style.display = finaleActive ? 'none' : '';
camera.getWorldDirection(fwd); fwd.y = 0; fwd.normalize();
@@ -3349,9 +3391,12 @@ function animate(time) {
if (len > worldLim()) hero.position.multiplyScalar(worldLim() / len);
resolveHeroCollision(); // 剛體碰撞:把玩家推出重疊的實體(撞牆停住、沿牆滑)
const tr = Math.atan2(moveDir.x, moveDir.z);
- if (!spinning) hero.rotation.y += ((((tr - hero.rotation.y) % TAU) + Math.PI * 3) % TAU - Math.PI) * Math.min(1, 12 * dt);
+ if (!spinning && !fpv) hero.rotation.y += ((((tr - hero.rotation.y) % TAU) + Math.PI * 3) % TAU - Math.PI) * Math.min(1, 12 * dt);
moving = true;
}
+ // 第一人稱:身體恆跟著視線(不是跟著移動方向)。否則按 A 側移時身體會轉開,而火把與地面光錐是吃 hero.rotation.y 的 → 夜裡光束會憑空甩到旁邊。
+ // 相機視線水平方向是 (-sin yaw, -cos yaw)、主角正面是 (sin ry, cos ry) → ry = yaw + π。二段跳轉圈時讓位給 spin。
+ if (fpv && !spinning && !finaleActive) hero.rotation.y = yaw + Math.PI;
// 跳躍:空白鍵或手機跳躍鍵;在地面起跳,空中再按一次=二段跳並轉一圈;終局運鏡時禁用
const grounded = jumpOff <= 0;
@@ -3409,14 +3454,28 @@ function animate(time) {
}
wasInWater = inWater;
- // 鏡頭:第三人稱(避免穿地)——檔案室已改原地面板,不再有第一人稱模式
+ // 鏡頭:第一/第三人稱(V 或右上角 👁 切換)。終局運鏡自己接管相機 → 那時一律走第三人稱、把頭裝回去
{
- camPos.set(hero.position.x + Math.sin(yaw) * Math.cos(pitch) * dist, hero.position.y + Math.sin(pitch) * dist + 2, hero.position.z + Math.cos(yaw) * Math.cos(pitch) * dist);
- const camGround = terrainHeight(camPos.x, camPos.z) + 3;
- if (camPos.y < camGround) camPos.y = camGround;
- camera.position.lerp(camPos, 1 - Math.exp(-6 * dt));
- if (camera.position.distanceToSquared(camPos) < 1e-4) camera.position.copy(camPos); // 夠近就吸附=相機完全停住;否則指數逼近永遠到不了,靜止後相機仍每幀次像素微動 → 水岸線等高對比邊緣持續閃爍(看起來像水面在抖)
- lookAt.set(hero.position.x, hero.position.y + 2.4, hero.position.z); camera.lookAt(lookAt);
+ const inFP = fpv && !finaleActive;
+ // 只藏頭:髮與眼是 head 的子物件會一起消失。不整個 hero 隱藏——three.js 中 visible=false 就不投影,白天主角影子會憑空不見。
+ // 留著身體還能低頭看到自己,沉浸感更好;眼球在頭前 0.45m、near plane 才 0.1,不藏頭會有兩顆黑點糊在畫面中央。
+ head.visible = !inFP;
+ if (inFP) {
+ // 不 lerp、不夾地:相機本來就長在身上,插值只會變成拖曳感,而視點恆在地面之上。
+ // 走路時 hero.position.y 已有 0.12 的踏步起伏 → 第一人稱的鏡頭晃動是免費附送的。
+ const fx = -Math.sin(yaw), fz = -Math.cos(yaw); // 視線的水平方向(與 hero.rotation.y = yaw + π 同向)
+ camera.position.set(hero.position.x + fx * FP_FWD, hero.position.y + FP_EYE, hero.position.z + fz * FP_FWD);
+ const cp = Math.cos(pitch); // 視線=第三人稱「主角→相機」方位取負號,所以兩模式的 yaw/pitch 可以直接共用
+ lookAt.set(camera.position.x + fx * cp, camera.position.y - Math.sin(pitch), camera.position.z + fz * cp);
+ camera.lookAt(lookAt);
+ } else {
+ camPos.set(hero.position.x + Math.sin(yaw) * Math.cos(pitch) * dist, hero.position.y + Math.sin(pitch) * dist + 2, hero.position.z + Math.cos(yaw) * Math.cos(pitch) * dist);
+ const camGround = terrainHeight(camPos.x, camPos.z) + 3;
+ if (camPos.y < camGround) camPos.y = camGround;
+ camera.position.lerp(camPos, 1 - Math.exp(-6 * dt));
+ if (camera.position.distanceToSquared(camPos) < 1e-4) camera.position.copy(camPos); // 夠近就吸附=相機完全停住;否則指數逼近永遠到不了,靜止後相機仍每幀次像素微動 → 水岸線等高對比邊緣持續閃爍(看起來像水面在抖)
+ lookAt.set(hero.position.x, hero.position.y + 2.4, hero.position.z); camera.lookAt(lookAt);
+ }
}
// 終局運鏡:鏡頭飛向村莊中央大水晶,看完噴發動畫後才彈出完成畫面
if (finaleActive) {