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) {