// ========================================== // 投擲物 (手榴彈 / 閃光彈):第一人稱「空手」手臂 (fp_arms.glb) + 投擲動畫 + 手榴彈飛行 / 彈跳 / 爆炸 // // 手臂模型本身沒有動畫,動作是程式算的: // 每個關鍵影格只寫「手腕要在哪裡 (相機座標,公尺)」、「手指握多緊」, // 肩膀 / 手肘的角度用 IK (兩節手臂反向運動) 自動算出來,影格之間用平滑曲線內插。 // 想調動作:改下面各段 (DRAW / PULL / THROW / PUTAWAY) 裡的數字 (x = 右、y = 上、z = 前,相機在 0,0,0) // // 操作 (切換式):按「手榴彈」→ 槍收起來、拿出手榴彈 (拿著) // 按住 FIRE:左手拔插銷 (先用力拉、卡一下才「啵」拔出來) → 左手抬起來瞄準、右手往後拉 (按著就一直瞄) // 放開 FIRE:丟出去 (身體跟著轉、往前甩) → 換回原本的槍 // 還沒拔插銷前再按一次「手榴彈」(或 SWAP) = 收起來不丟 // 手榴彈:拋物線飛行,碰到地面 / 牆會彈跳、滾一下;握片彈開 (丟出去) 後 GRENADE.FUSE 秒爆炸 // 爆炸:強光 + 火球 + 濃煙 + 土塊,地上留下燒焦的痕跡和淺坑 // ========================================== import { MAIN_MASK, ENV_MASK } from "./common.js"; import { assetUrl, assetErrors } from "./assets.js"; import { getFxTextures, makeSmokePS } from "./weaponfx.js"; export const GRENADE = { COUNT: 3, // 一場遊戲幾顆 FUSE: 3.6, // 拔插銷後幾秒爆炸 SPEED: 16.5, // 丟出去的速度 (公尺/秒):慢一點、拋得高一點 (用 UP 補),大約可以丟 22~26 公尺 UP: 0.5, // 往上多丟一點 (仰角加成) PITCH_DOWN: 10, // 丟出去的方向再往下壓幾度 (配合手的動畫:手是往前下方甩出去的) BOUNCE: 0.32, // 彈跳 (0 = 不彈,1 = 完全彈回) FRICTION: 0.55, // 碰地時水平速度剩多少 RADIUS_KILL: 7, // 這個距離內:死亡 RADIUS_DOWN: 11, // 這個距離內:重傷倒地 RADIUS_HURT: 20, // 這個距離內:受驚 / 壓制 PLAYER_HURT: 9, // 玩家在這個距離內會被炸到 (畫面震動 + 紅) }; // 閃光彈 (M84 那種):不會炸傷人,爆開時強光 + 巨響 → 看著它的人會暫時看不見、耳鳴 export const FLASH = { FUSE: 1.8, // 握片彈開後幾秒爆開 RADIUS: 20, // 這個距離內會被閃到 (越近越嚴重;背對 / 有東西擋住會輕很多) }; // 煙霧彈 (M18 白煙):丟出去 FUSE 秒後開始冒煙 (不管那時候還在空中還是已經掉在地上),煙從罐子冒出來,慢慢擴散成一大團白煙 export const SMOKE = { FUSE: 1.0, // 握片彈開 (丟出) 後幾秒開始冒煙 DURATION: 60, // 冒煙持續幾秒 (之後煙慢慢散掉,再多約 12 秒完全消失) RADIUS: 8.5, // 煙團最大半徑 (公尺):在這範圍裡敵人看不到你 (也看不到你身後的東西) GROW: 7, // 幾秒長到最大 MAX: 2, // 同時最多幾團煙 (太多手機會卡,超過就把最舊的收掉) RATE: 6, // 每秒噴幾顆煙團 (越多越濃、越吃效能) PUFF: 30, // 一開始先噴一大口 SIZE: [3.4, 4.8], // 每顆煙團一開始的大小 (公尺);會再膨脹 2.2 倍 SPREAD: 1.9, // 煙往外擴散的速度 (越大散得越開) ALPHA: 0.78, // 煙的濃度 (越大越不透明,白煙在亮的天空前面比較看得清楚) }; // 手上那顆手榴彈的放大倍率 (真實大小 = 100,數字越大手上看起來越大;飛出去的也跟著放大) const HELD_SCALE = 135; const SIZE_K = { frag: 1, flash: 0.72, smoke: 0.78 }; // 各種投擲物在手上 / 飛出去的大小倍率 (煙霧彈罐子比較大,縮一點才不會超出畫面) const HELD_POS = [-2.5, -4.5, -9]; // 手上手榴彈在右手腕骨頭座標裡的基本位置 const HELD_SHIFT = [1, 3, 5]; // 再朝畫面 [右, 上, 前] 移的距離 (公分,以拿著手榴彈的姿勢為準) // 手臂容器相對相機的位置 (肩膀大概在眼睛下方、後方) const ARMS_OFFSET = [0, -0.36, 0.02]; // 姿勢:R / L = 右手 / 左手腕位置 (相機座標,公尺),Rg / Lg = 手指握的程度 (0 張開 ~ 1 握拳) // Rf / Lf = 手指指的方向;Rn / Ln = 手掌心朝的方向;B = 上半身 [左右轉, 前後傾, 側傾, 上下] (轉身 = 有重量感) // tr = 手抖的程度 (拔插銷用力時) const P_OFF = { R: [0.2, -0.55, 0.18], L: [-0.2, -0.58, 0.15], Rg: 0.85, Lg: 0.35, Rf: [0, 0.6, 1], Lf: [0, 0.6, 1], Rn: [-0.8, -0.4, 0.2], Ln: [0.8, -0.4, 0.2], B: [0, 0, 0, -0.03], tr: 0 }; const P_HOLD = { R: [0.14, -0.13, 0.31], L: [-0.15, -0.27, 0.26], Rg: 0.3, Lg: 0.35, Rf: [-0.3, 0.7, 1], Lf: [0.3, 0.5, 1], Rn: [-1, 0.1, 0.1], Ln: [0.6, -0.6, 0.2], B: [0, 0, 0, 0], tr: 0 }; const P_MEET = { R: [0.09, -0.12, 0.31], L: [-0.115, -0.005, 0.370], Rg: 0.3, Lg: 0.85, Rf: [-0.4, 0.7, 1], Lf: [0.8, 0.4, 0.7], Rn: [-1, 0.1, 0.1], Ln: [0.4, -0.8, 0.3], B: [0, 0.02, 0, -0.005], tr: 0.2 }; const P_STRAIN = { R: [0.10, -0.12, 0.31], L: [-0.132, -0.010, 0.365], Rg: 0.3, Lg: 0.95, Rf: [-0.4, 0.7, 1], Lf: [0.8, 0.3, 0.7], Rn: [-1, 0.1, 0.1], Ln: [0.4, -0.8, 0.3], B: [0.01, 0.03, -0.01, -0.01], tr: 1 }; // 用力拉,插銷還卡著 const P_POP = { R: [0.115, -0.115, 0.31], L: [-0.220, -0.030, 0.350], Rg: 0.3, Lg: 0.95, Rf: [-0.3, 0.7, 1], Lf: [0.4, 0.3, 1], Rn: [-1, 0.1, 0.1], Ln: [0.3, -0.9, 0.2], B: [-0.01, 0.01, 0.01, 0], tr: 0 }; // 啵!拔出來 const P_AIM = { R: [0.29, -0.06, 0.0], L: [-0.11, -0.02, 0.45], Rg: 0.35, Lg: 0.12, Rf: [0.15, 0.95, -0.2], Lf: [0.15, 0.4, 1], Rn: [0, 0.2, 1], Ln: [0.4, -1, 0.1], B: [0.12, -0.03, 0.03, 0], tr: 0 }; // 左手抬起來瞄準、右手往後拉 const P_BACK = { R: [0.31, -0.02, -0.07], L: [-0.09, -0.01, 0.46], Rg: 0.35, Lg: 0.12, Rf: [0.2, 0.9, -0.4], Lf: [0.15, 0.45, 1], Rn: [0, 0.3, 1], Ln: [0.4, -1, 0.1], B: [0.18, -0.05, 0.05, 0.005], tr: 0 }; // 丟之前再往後蓄力 const P_FWD = { R: [0.22, 0.06, 0.24], L: [-0.2, -0.17, 0.3], Rg: 0.35, Lg: 0.3, Rf: [0, 0.8, 1], Lf: [0.2, 0.1, 1], Rn: [0, -0.3, 1], Ln: [0.4, -1, 0], B: [-0.05, 0.04, -0.02, -0.01], tr: 0 }; const P_REL = { R: [0.1, -0.03, 0.44], L: [-0.24, -0.27, 0.22], Rg: 0.1, Lg: 0.3, Rf: [-0.2, 0.2, 1], Lf: [0.2, 0.1, 1], Rn: [0, -1, 0.2], Ln: [0.4, -1, 0], B: [-0.12, 0.08, -0.04, -0.02], tr: 0 }; const P_FOLLOW = { R: [0.0, -0.22, 0.38], L: [-0.24, -0.42, 0.18], Rg: 0.25, Lg: 0.3, Rf: [-0.4, -0.4, 1], Lf: [0, 0.4, 1], Rn: [-0.5, -1, 0], Ln: [0.6, -0.6, 0.2], B: [-0.14, 0.1, -0.03, -0.03], tr: 0 }; const P_DOWN = { R: [0.12, -0.62, 0.2], L: [-0.2, -0.65, 0.15], Rg: 0.3, Lg: 0.3, Rf: [0, 0.6, 1], Lf: [0, 0.6, 1], Rn: [-0.8, -0.4, 0.2], Ln: [0.8, -0.4, 0.2], B: [-0.05, 0.04, 0, -0.03], tr: 0 }; // 空手 (沒帶槍):手臂用模型原本的姿勢,位置再移 EMPTY_OFFSET (相機座標,公尺:[右, 上, 前]) const EMPTY_OFFSET = [0, 0.235, 0.04]; // (舊的空手姿勢,現在沒用到) const P_EMPTY = { R: [0.2, -0.165, 0.43], L: [-0.2, -0.172, 0.42], Rg: 0.72, Lg: 0.72, Rf: [-0.15, 0.55, 1], Lf: [0.15, 0.55, 1], Rn: [-0.9, -0.25, 0.2], Ln: [0.9, -0.25, 0.2], B: [0, 0, 0, 0], tr: 0 }; const K = (t, p, extra) => Object.assign({ t }, p, extra || {}); // 各段動畫 (t = 秒) const CLIPS = { draw: [K(0, P_OFF), K(0.38, P_HOLD)], pull: [K(0, P_HOLD), K(0.2, P_MEET), K(0.5, P_STRAIN), K(0.56, P_POP, { pin: true }), K(0.74, P_POP, { L: [-0.31, -0.08, 0.38] }), K(1.02, P_AIM)], // 丟的動作:慢慢往後拉 (有阻力、手微微出力) → 停一下蓄力 → 才往前甩出去 → 放手 → 順勢收回 throw: [K(0, P_AIM), K(0.45, P_BACK, { tr: 0.3 }), K(0.58, P_BACK, { tr: 0.5 }), K(0.82, P_FWD), K(0.91, P_REL, { release: true }), K(1.2, P_FOLLOW), K(1.6, P_DOWN)], putaway: [K(0, P_HOLD), K(0.32, P_OFF)], }; const ELBOW_HINT = { R: [0.9, -0.9, -0.3], L: [-0.9, -0.9, -0.3] }; // 手肘大致往外下方 const BONES = { R: { arm: "R_arm_024", elbow: "R_elbow_025", wrist: "R_wrist_026", mid: "R_middle1_035", side: "R_point1_031", fingers: [["R_point1_031", "R_point2_032", "R_point3_033"], ["R_middle1_035", "R_middle2_036", "R_middle3_037"], ["R_ring1_040", "R_ring2_041", "R_ring3_042"], ["R_pink1_044", "R_pink2_045", "R_pink3_046"]], thumb: ["R_thumb1_027", "R_thumb2_028", "R_thumb3_029"] }, L: { arm: "L_arm_01", elbow: "L_elbow_02", wrist: "L_wrist_03", mid: "L_middle1_012", side: "L_point1_08", fingers: [["L_point1_08", "L_point2_09", "L_point3_010"], ["L_middle1_012", "L_middle2_013", "L_middle3_00"], ["L_ring1_016", "L_ring2_017", "L_ring3_018"], ["L_pink1_020", "L_pink2_021", "L_pink3_022"]], thumb: ["L_thumb1_04", "L_thumb2_05", "L_thumb3_06"] }, }; // ---------- 小工具 ---------- const V3 = () => BABYLON.Vector3; function updateChain(node) { const st = []; let n = node; while (n) { st.push(n); n = n.parent; } for (let i = st.length - 1; i >= 0; i--) st[i].computeWorldMatrix(true); } // 轉動節點,讓它的 childLocal 方向指向世界方向 D;secLocal (可省略) 轉到盡量對齊世界方向 S function aimNode(node, childLocal, D, secLocal, S) { const V = V3(), Q = BABYLON.Quaternion, M = BABYLON.Matrix; updateChain(node.parent); const Pinv = M.Invert(node.parent.getWorldMatrix()); const Dp = V.TransformNormal(D, Pinv).normalize(); let q = node.rotationQuaternion; if (!q) q = node.rotationQuaternion = Q.FromEulerVector(node.rotation); const cv = V.Zero(); childLocal.rotateByQuaternionToRef(q, cv); cv.normalize(); const q1 = Q.Identity(); Q.FromUnitVectorsToRef(cv, Dp, q1); let qn = q1.multiply(q); if (secLocal && S) { const sv = V.Zero(); secLocal.rotateByQuaternionToRef(qn, sv); const Sp = V.TransformNormal(S, Pinv); const a = sv.subtract(Dp.scale(V.Dot(sv, Dp))), b = Sp.subtract(Dp.scale(V.Dot(Sp, Dp))); if (a.lengthSquared() > 1e-8 && b.lengthSquared() > 1e-8) { const ang = Math.atan2(V.Dot(V.Cross(a, b), Dp), V.Dot(a, b)); qn = Q.RotationAxis(Dp, ang).multiply(qn); } } node.rotationQuaternion = qn.normalize(); node.computeWorldMatrix(true); } const smooth = (k) => k * k * (3 - 2 * k); // Catmull-Rom (每個分量) function crom(p0, p1, p2, p3, t) { const t2 = t * t, t3 = t2 * t; return p1.map((_, i) => 0.5 * ((2 * p1[i]) + (-p0[i] + p2[i]) * t + (2 * p0[i] - 5 * p1[i] + 4 * p2[i] - p3[i]) * t2 + (-p0[i] + 3 * p1[i] - 3 * p2[i] + p3[i]) * t3)); } function sampleKeys(keys, t, field) { if (t <= keys[0].t) return keys[0][field]; for (let i = 0; i < keys.length - 1; i++) { const a = keys[i], b = keys[i + 1]; if (t <= b.t) { const k = (t - a.t) / (b.t - a.t); const v = (x) => (Array.isArray(x) ? x : [x]); const p0 = v((keys[i - 1] || a)[field]), p1 = v(a[field]), p2 = v(b[field]), p3 = v((keys[i + 2] || b)[field]); const r = crom(p0, p1, p2, p3, smooth(k)); return Array.isArray(a[field]) ? r : r[0]; } } return keys[keys.length - 1][field]; } // ---------- 載入手臂 (要在 createScene 裡、釋放下載快取之前呼叫) ---------- export async function loadThrowArms(scene, camera) { let res; try { res = await BABYLON.SceneLoader.ImportMeshAsync("", assetUrl("fp_arms"), "", scene, null, ".glb"); } catch (e) { console.warn("throw arms: load failed", e && e.message); return null; } const holder = new BABYLON.TransformNode("throwArmsHolder", scene); holder.parent = camera; holder.position.set(ARMS_OFFSET[0], ARMS_OFFSET[1], ARMS_OFFSET[2]); const root = res.meshes[0]; root.parent = holder; for (const m of res.meshes) { m.layerMask = MAIN_MASK; m.isPickable = false; m.alwaysSelectAsActiveMesh = true; m.receiveShadows = false; } // 先把著色器編譯好 (不然第一次丟的時候會卡一下) for (const m of res.meshes) if (m.material && m.getTotalVertices() > 0) { try { await m.material.forceCompilationAsync(m); } catch (e) { /* 忽略 */ } } holder.setEnabled(false); const node = (n) => scene.getTransformNodeByName(n); // 記錄每根骨頭原本的旋轉 (手指彎曲用) const rest = new Map(); for (const side of ["R", "L"]) { const B = BONES[side]; for (const n of [B.arm, B.elbow, B.wrist, ...B.fingers.flat(), ...B.thumb]) { const t = node(n); if (!t) continue; if (!t.rotationQuaternion) t.rotationQuaternion = BABYLON.Quaternion.FromEulerVector(t.rotation); rest.set(n, t.rotationQuaternion.clone()); } } // 手掌心在手腕骨頭座標裡的方向:原本的姿勢手掌朝下 (世界 -Y) const palm = {}; for (const side of ["R", "L"]) { const w = node(BONES[side].wrist); updateChain(w); palm[side] = BABYLON.Vector3.TransformNormal(new BABYLON.Vector3(0, -1, 0), BABYLON.Matrix.Invert(w.getWorldMatrix())).normalize(); } return { holder, root, node, rest, palm, meshes: res.meshes }; } export const modelErrors = {}; // 模型載入失敗的原因 (遊戲裡切到那種投擲物時提示) // ---------- 手榴彈模型 (rgd5.glb:RGD-5,作者 Навіжена Лярва,CC-BY-4.0) ---------- // 拆成三塊:本體 (含引信)、插銷 + 拉環 (拔掉後消失)、握片 (丟出去的瞬間彈飛) // 縮放到真實大小 (本體直徑 5.8 公分),本體中心在原點、往上 = +Y export async function loadGrenadeModel(scene) { let res; try { res = await BABYLON.SceneLoader.ImportMeshAsync("", assetUrl("rgd5"), "", scene, null, ".glb"); } catch (e) { console.warn("grenade model: load failed (用程式做的手榴彈代替)", e && e.message); return null; } const V = BABYLON.Vector3, M = BABYLON.Matrix; const ms = res.meshes.filter((m) => m.getTotalVertices() > 0); const find = (k) => ms.filter((m) => m.name.indexOf(k) === 0); const bodyM = find("Granata")[0]; if (!bodyM) { res.meshes.forEach((m) => m.dispose()); return null; } ms.forEach((m) => m.computeWorldMatrix(true)); const bb = bodyM.getBoundingInfo().boundingBox, c = bb.centerWorld.clone(); const S = 0.058 / Math.max(1e-6, bb.maximumWorld.x - bb.minimumWorld.x); // 握片在模型的 +X 那一側 → 轉 90° 放到 +Z (跟程式做的手榴彈一樣,手上握的方向才對) const xf = M.Translation(-c.x, -c.y, -c.z).multiply(M.Scaling(S, S, S)).multiply(M.RotationY(-Math.PI / 2)); const bake = (list, name) => { if (!list.length) return null; const parts = list.map((m) => { const vd = BABYLON.VertexData.ExtractFromMesh(m, true, true); const out = new BABYLON.Mesh(name + "_p", scene); vd.applyToMesh(out, true); out.material = m.material; const W = m.getWorldMatrix().multiply(xf); out.bakeTransformIntoVertices(W); if (W.determinant() < 0) out.flipFaces(false); return out; }); const merged = parts.length > 1 ? BABYLON.Mesh.MergeMeshes(parts, true, true, undefined, false, false) : parts[0]; merged.name = name; merged.isPickable = false; merged.setEnabled(false); return merged; }; const body = bake(find("Granata").concat(find("Pruzina")), "rgdBody"); const pin = bake(find("Kolco").concat(find("Pimpa")), "rgdPin"); const lever = bake(find("Ruchka"), "rgdLever"); res.meshes.forEach((m) => { try { m.dispose(false, false); } catch (e) { /* 忽略 */ } }); for (const m of [body, pin, lever]) if (m && m.material) { try { await m.material.forceCompilationAsync(m); } catch (e) { /* 忽略 */ } } return { body, pin, lever }; } // ---------- 閃光彈模型 (flashbang_grenade.glb,作者 Nikolay Kudrin,CC-BY-4.0) ---------- // 一樣拆成三塊:本體 (外殼 + 內筒 + 引信)、插銷 + 拉環、握片。真實大小 (直徑約 4.4 公分),握片轉到 +Z,往上 = +Y export async function loadFlashModel(scene) { let res; try { res = await BABYLON.SceneLoader.ImportMeshAsync("", assetUrl("flashbang_grenade"), "", scene, null, ".glb"); } catch (e) { console.warn("flashbang model: load failed (用程式做的代替)", e && e.message); return null; } const M = BABYLON.Matrix; const ms = res.meshes.filter((m) => m.getTotalVertices() > 0); const find = (k) => ms.filter((m) => m.name.indexOf("Object." + k) === 0); const bodyM = find("001")[0]; if (!bodyM) { res.meshes.forEach((m) => m.dispose()); return null; } ms.forEach((m) => m.computeWorldMatrix(true)); const bb = bodyM.getBoundingInfo().boundingBox, c = bb.centerWorld.clone(); const S = 0.044 / Math.max(1e-6, bb.maximumWorld.x - bb.minimumWorld.x); const xf = M.Translation(-c.x, -c.y, -c.z).multiply(M.Scaling(S, S, S)).multiply(M.RotationY(Math.PI / 2)); // 握片在 -X → 轉到 +Z const bake = (list, name) => { if (!list.length) return null; const parts = list.map((m) => { const vd = BABYLON.VertexData.ExtractFromMesh(m, true, true); const out = new BABYLON.Mesh(name + "_p", scene); vd.applyToMesh(out, true); out.material = m.material; const W = m.getWorldMatrix().multiply(xf); out.bakeTransformIntoVertices(W); if (W.determinant() < 0) out.flipFaces(false); return out; }); const merged = parts.length > 1 ? BABYLON.Mesh.MergeMeshes(parts, true, true, undefined, false, false) : parts[0]; merged.name = name; merged.isPickable = false; merged.setEnabled(false); return merged; }; const body = bake(find("001").concat(find("003"), find("004")), "fbBody"); const pin = bake(find("007").concat(find("009"), find("008"), find("010")), "fbPin"); const lever = bake(find("005"), "fbLever"); res.meshes.forEach((m) => { try { m.dispose(false, false); } catch (e) { /* 忽略 */ } }); for (const m of [body, pin, lever]) if (m && m.material) { try { m.material.roughness = Math.max(m.material.roughness ?? 0.5, 0.55); await m.material.forceCompilationAsync(m); } catch (e) { /* 忽略 */ } } return { body, pin, lever }; } // ---------- 煙霧彈模型 (smoke_grenade.glb,作者 Ravi.Saini,CC-BY-4.0) ---------- // 一樣拆成三塊:本體 (罐子 + 引信)、插銷 + 拉環、握片。零件是用「上一層節點的名字」分的 (網格本身都叫 defaultMaterial) // 真實大小 (罐身直徑約 6.4 公分),握片轉到 +Z,往上 = +Y export async function loadSmokeModel(scene) { let res; try { res = await BABYLON.SceneLoader.ImportMeshAsync("", assetUrl("smoke_grenade"), "", scene, null, ".glb"); } catch (e) { console.warn("smoke model: load failed (用程式做的代替)", e && e.message); modelErrors.smoke = assetErrors.smoke_grenade || (e && e.message) || "讀不到"; return null; } const M = BABYLON.Matrix; const ms = res.meshes.filter((m) => m.getTotalVertices() > 0); const find = (k) => ms.filter((m) => m.parent && m.parent.name === k + "_low"); const canParts = find("can"); ms.forEach((m) => m.computeWorldMatrix(true)); let bodyM = null, bw = 0; for (const m of canParts) { const e = m.getBoundingInfo().boundingBox.extendSizeWorld; if (e.y > bw) { bw = e.y; bodyM = m; } } // 最高的那塊 = 罐身 if (!bodyM) { modelErrors.smoke = "模型裡的零件名稱對不上 (找不到 can_low)"; res.meshes.forEach((m) => m.dispose()); return null; } const bb = bodyM.getBoundingInfo().boundingBox, c = bb.centerWorld.clone(); const S = 0.064 / Math.max(1e-6, bb.maximumWorld.x - bb.minimumWorld.x); const xf = M.Translation(-c.x, -c.y, -c.z).multiply(M.Scaling(S, S, S)).multiply(M.RotationY(-Math.PI / 2)); // 握片在 +X → 轉到 +Z const bake = (list, name) => { if (!list.length) return null; const parts = list.map((m) => { const vd = BABYLON.VertexData.ExtractFromMesh(m, true, true); const out = new BABYLON.Mesh(name + "_p", scene); vd.applyToMesh(out, true); out.material = m.material; const W = m.getWorldMatrix().multiply(xf); out.bakeTransformIntoVertices(W); if (W.determinant() < 0) out.flipFaces(false); return out; }); const merged = parts.length > 1 ? BABYLON.Mesh.MergeMeshes(parts, true, true, undefined, false, false) : parts[0]; merged.name = name; merged.isPickable = false; merged.setEnabled(false); return merged; }; const body = bake(canParts.concat(find("fuse")), "smBody"); const pin = bake(find("pin").concat(find("ring")), "smPin"); const lever = bake(find("spoon"), "smLever"); res.meshes.forEach((m) => { try { m.dispose(false, false); } catch (e) { /* 忽略 */ } }); for (const m of [body, pin, lever]) if (m && m.material) { try { m.material.roughness = Math.max(m.material.roughness ?? 0.5, 0.55); m.material.backFaceCulling = false; await m.material.forceCompilationAsync(m); } catch (e) { /* 忽略 */ } } return { body, pin, lever }; } // 沒有煙霧彈模型時:程式做的灰綠色圓罐 function makeSmokeMesh(scene) { const body = BABYLON.MeshBuilder.CreateCylinder("smBodyP", { height: 0.12, diameter: 0.064, tessellation: 14 }, scene); const top = BABYLON.MeshBuilder.CreateCylinder("smTopP", { height: 0.02, diameter: 0.03, tessellation: 10 }, scene); top.position.y = 0.07; const m = BABYLON.Mesh.MergeMeshes([body, top], true, true, undefined, false, false); const mat = new BABYLON.PBRMaterial("smMat", scene); mat.albedoColor = new BABYLON.Color3(0.2, 0.25, 0.18); mat.metallic = 0.3; mat.roughness = 0.6; m.material = mat; m.name = "smokegrenade"; const ring = BABYLON.MeshBuilder.CreateTorus("smRing", { diameter: 0.024, thickness: 0.003, tessellation: 12 }, scene); ring.position.set(-0.02, 0.08, 0); ring.rotation.z = Math.PI / 2; const rmat = new BABYLON.PBRMaterial("smRingMat", scene); rmat.albedoColor = new BABYLON.Color3(0.6, 0.6, 0.58); rmat.metallic = 0.9; rmat.roughness = 0.35; ring.material = rmat; return { mesh: m, ring, ringPos: [-0.02, 0.08, 0], lever: null }; } // 沒有閃光彈模型時:程式做的圓筒 function makeFlashMesh(scene) { const body = BABYLON.MeshBuilder.CreateCylinder("fbBodyP", { height: 0.11, diameter: 0.044, tessellation: 12 }, scene); const top = BABYLON.MeshBuilder.CreateCylinder("fbTopP", { height: 0.025, diameter: 0.024, tessellation: 10 }, scene); top.position.y = 0.065; const m = BABYLON.Mesh.MergeMeshes([body, top], true, true, undefined, false, false); const mat = new BABYLON.PBRMaterial("fbMat", scene); mat.albedoColor = new BABYLON.Color3(0.32, 0.34, 0.26); mat.metallic = 0.3; mat.roughness = 0.6; m.material = mat; m.name = "flashbang"; const ring = BABYLON.MeshBuilder.CreateTorus("fbRing", { diameter: 0.024, thickness: 0.003, tessellation: 12 }, scene); ring.position.set(-0.02, 0.07, 0); ring.rotation.z = Math.PI / 2; const rmat = new BABYLON.PBRMaterial("fbRingMat", scene); rmat.albedoColor = new BABYLON.Color3(0.6, 0.6, 0.58); rmat.metallic = 0.9; rmat.roughness = 0.35; ring.material = rmat; return { mesh: m, ring, ringPos: [-0.02, 0.07, 0], lever: null }; } // ---------- 手榴彈網格 (沒有模型時,程式做的:橢圓本體 + 頂蓋 + 握把) ---------- function makeGrenadeMesh(scene) { const body = BABYLON.MeshBuilder.CreateSphere("gnBody", { diameterX: 0.062, diameterY: 0.085, diameterZ: 0.062, segments: 10 }, scene); const top = BABYLON.MeshBuilder.CreateCylinder("gnTop", { height: 0.028, diameter: 0.026, tessellation: 10 }, scene); top.position.y = 0.05; const lever = BABYLON.MeshBuilder.CreateBox("gnLever", { width: 0.012, height: 0.07, depth: 0.006 }, scene); lever.position.set(0, 0.018, 0.033); lever.rotation.x = -0.12; const ring = BABYLON.MeshBuilder.CreateTorus("gnRing", { diameter: 0.024, thickness: 0.003, tessellation: 12 }, scene); ring.position.set(-0.02, 0.055, 0); ring.rotation.z = Math.PI / 2; const m = BABYLON.Mesh.MergeMeshes([body, top, lever], true, true, undefined, false, false); const mat = new BABYLON.PBRMaterial("gnMat", scene); mat.albedoColor = new BABYLON.Color3(0.11, 0.14, 0.08); mat.metallic = 0.2; mat.roughness = 0.62; m.material = mat; m.name = "grenade"; const rmat = new BABYLON.PBRMaterial("gnRingMat", scene); rmat.albedoColor = new BABYLON.Color3(0.6, 0.6, 0.58); rmat.metallic = 0.9; rmat.roughness = 0.35; ring.material = rmat; return { mesh: m, ring, ringPos: [-0.02, 0.055, 0], lever: null }; } // ---------- 爆炸特效:閃光 + 火球 + 濃煙 + 噴飛的土塊 ---------- export function createBlastFx(scene) { const V = BABYLON.Vector3, C4 = BABYLON.Color4, tex = getFxTextures(scene); const ps = (name, cap, t, o) => { const p = new BABYLON.ParticleSystem(name, cap, scene); p.particleTexture = t; p.layerMask = ENV_MASK; p.emitter = V.Zero(); p.updateSpeed = 1 / 60; p.renderingGroupId = 1; // 在半透明的水面之後畫 (河上爆炸也看得到) p.blendMode = o.add ? BABYLON.ParticleSystem.BLENDMODE_ADD : BABYLON.ParticleSystem.BLENDMODE_STANDARD; p.minLifeTime = o.life[0]; p.maxLifeTime = o.life[1]; p.minEmitPower = o.speed[0]; p.maxEmitPower = o.speed[1]; p.minInitialRotation = 0; p.maxInitialRotation = 6.28; p.gravity = new V(0, o.g || 0, 0); p.addSizeGradient(0, o.size[0], o.size[1]); p.addSizeGradient(1, o.size[0] * (o.grow || 1), o.size[1] * (o.grow || 1)); o.cols.forEach(([k, c]) => p.addColorGradient(k, new C4(c[0], c[1], c[2], c[3]))); if (o.drag) { p.addVelocityGradient(0, 1); p.addVelocityGradient(0.3, o.drag); p.addVelocityGradient(1, o.drag * 0.5); } p.emitRate = 0; p.manualEmitCount = 0; p.start(); return p; }; // 強光:中間很亮的黃白色 + 外面一大圈刺眼的光暈 (一瞬間) const flash = ps("fxBlastFlash", 10, tex.flash || tex.smoke, { add: true, life: [0.09, 0.16], speed: [0, 0.3], size: [3.6, 5.6], grow: 1.5, cols: [[0, [1, 1, 0.85, 1]], [0.4, [1, 0.85, 0.4, 1]], [1, [1, 0.55, 0.15, 0]]] }); const glare = ps("fxBlastGlare", 4, tex.smoke, { add: true, life: [0.12, 0.2], speed: [0, 0], size: [9, 13], grow: 1.3, cols: [[0, [1, 0.92, 0.55, 0.9]], [1, [1, 0.7, 0.25, 0]]] }); const fire = ps("fxBlastFire", 40, tex.smoke, { add: true, life: [0.18, 0.4], speed: [2, 6], size: [0.9, 1.7], grow: 2, drag: 0.25, cols: [[0, [1, 0.75, 0.35, 1]], [0.5, [0.9, 0.35, 0.08, 0.8]], [1, [0.3, 0.1, 0.02, 0]]] }); const smoke = makeSmokePS(scene, "fxBlastSmoke", 60, tex.smoke, { mask: ENV_MASK, size: [1.0, 1.8], grow: 3.2, life: [2.6, 4.2], speed: [1.5, 4.5], slow: 0.12, lift: 0.5, spin: 0.4, color: [0.3, 0.28, 0.26], alpha: 0.8 }); smoke.renderingGroupId = 1; const dirt = ps("fxBlastDirt", 80, tex.debris || tex.smoke, { life: [0.9, 1.6], speed: [5, 12], size: [0.05, 0.14], g: -9.8, cols: [[0, [0.25, 0.2, 0.15, 1]], [1, [0.22, 0.18, 0.14, 1]]] }); return { boom(p) { const at = new V(p.x, p.y + 0.15, p.z); for (const s of [flash, glare, fire, smoke, dirt]) s.emitter = at; flash.createSphereEmitter(0.2, 0); flash.manualEmitCount = 6; glare.createPointEmitter(V.Zero(), V.Zero()); glare.manualEmitCount = 2; fire.createSphereEmitter(0.4, 0); fire.manualEmitCount = 26; smoke.createHemisphericEmitter(0.6, 0); smoke.manualEmitCount = 22; dirt.createConeEmitter(0.4, Math.PI / 3); dirt.manualEmitCount = 60; }, }; } // ---------- 地上的燒焦痕跡 + 淺坑 ---------- // 爆炸點下面如果是地形:把附近的地面往下壓一點 (淺坑),地面上的小草拿掉,再貼一張灰黑色的燒焦貼圖 export const SCORCH = { SIZE: 4.2, CRATER_R: 2.1, CRATER_D: 0.42, MAX: 8 }; export function createScorchFx(scene, opts = {}) { const V = BABYLON.Vector3; const tex = new BABYLON.DynamicTexture("scorchTex", { width: 256, height: 256 }, scene, true); tex.hasAlpha = true; { const c = tex.getContext(), S = 256, C = S / 2; c.clearRect(0, 0, S, S); let seed = 9; const R = () => (seed = (seed * 16807) % 2147483647) / 2147483647; // 中間最黑、往外變灰、邊緣不規則地淡出 for (let i = 0; i < 40; i++) { const a = R() * 6.28, d = R() * S * 0.22, x = C + Math.cos(a) * d, y = C + Math.sin(a) * d, r = S * (0.12 + R() * 0.2); const g = c.createRadialGradient(x, y, 0, x, y, r); const k = 20 + R() * 25; g.addColorStop(0, `rgba(${k},${k},${k},0.55)`); g.addColorStop(1, `rgba(${k + 30},${k + 28},${k + 26},0)`); c.fillStyle = g; c.beginPath(); c.arc(x, y, r, 0, 6.29); c.fill(); } const g = c.createRadialGradient(C, C, 0, C, C, S * 0.2); g.addColorStop(0, "rgba(10,10,10,0.85)"); g.addColorStop(1, "rgba(25,24,22,0)"); c.fillStyle = g; c.fillRect(0, 0, S, S); // 放射狀的噴濺紋 for (let i = 0; i < 60; i++) { const a = R() * 6.28, r0 = S * (0.08 + R() * 0.1), r1 = S * (0.25 + R() * 0.22); c.strokeStyle = `rgba(30,29,27,${0.08 + R() * 0.16})`; c.lineWidth = 1 + R() * 2.5; c.beginPath(); c.moveTo(C + Math.cos(a) * r0, C + Math.sin(a) * r0); c.lineTo(C + Math.cos(a) * r1, C + Math.sin(a) * r1); c.stroke(); } tex.update(true); } const mat = new BABYLON.StandardMaterial("scorchMat", scene); mat.diffuseTexture = tex; mat.useAlphaFromDiffuseTexture = true; mat.specularColor = new BABYLON.Color3(0, 0, 0); mat.zOffset = -3; mat.backFaceCulling = false; mat.disableDepthWrite = true; const decals = []; const envPred = (m) => m.isPickable && m.isEnabled() && m.isVisible && m.metadata && m.metadata.env; // 隱形的碰撞方塊不貼燒焦痕跡 // 地形是格子 (metadata.grid):只改爆炸點附近幾格,只上傳改到的那幾排 (以前整片地形重算法線 + 重建緩衝區,一次爆炸卡 0.2~0.5 秒) const eng = scene.getEngine(); function gridOf(mesh) { const G = mesh.metadata && mesh.metadata.grid; if (!G) return null; if (!G.P) { // 第一次:拿一份頂點 / 法線,換成可以更新的緩衝區 (之後都只更新一部分) G.P = Float32Array.from(mesh.getVerticesData(BABYLON.VertexBuffer.PositionKind)); G.N = Float32Array.from(mesh.getVerticesData(BABYLON.VertexBuffer.NormalKind)); mesh.setVerticesData(BABYLON.VertexBuffer.PositionKind, G.P, true); mesh.setVerticesData(BABYLON.VertexBuffer.NormalKind, G.N, true); } return G; } const lowerIdx = (a, v) => { let lo = 0, hi = a.length - 1; while (lo < hi) { const m = (lo + hi + 1) >> 1; if (a[m] <= v) lo = m; else hi = m - 1; } return lo; }; function crater(mesh, p) { const G = gridOf(mesh); if (!G) return 0; const R = SCORCH.CRATER_R, D = SCORCH.CRATER_D, { xs, zs, nx, nz, P, N } = G; const i0 = Math.max(0, lowerIdx(xs, p.x - R)), i1 = Math.min(nx - 1, lowerIdx(xs, p.x + R) + 1); const j0 = Math.max(0, lowerIdx(zs, p.z - R)), j1 = Math.min(nz - 1, lowerIdx(zs, p.z + R) + 1); let changed = 0; for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) { const k = (j * nx + i) * 3, dx = P[k] - p.x, dz = P[k + 2] - p.z, d2 = dx * dx + dz * dz; if (d2 >= R * R) continue; const f = 1 - d2 / (R * R); P[k + 1] -= D * f * f; changed++; } if (!changed) return 0; // 法線:改到的範圍再往外一格,用左右 / 前後的高度差算 (跟建地形時一樣) const a0 = Math.max(0, i0 - 1), a1 = Math.min(nx - 1, i1 + 1), b0 = Math.max(0, j0 - 1), b1 = Math.min(nz - 1, j1 + 1); for (let j = b0; j <= b1; j++) for (let i = a0; i <= a1; i++) { const im = Math.max(0, i - 1), ip = Math.min(nx - 1, i + 1), jm = Math.max(0, j - 1), jp = Math.min(nz - 1, j + 1); const hx = (P[(j * nx + ip) * 3 + 1] - P[(j * nx + im) * 3 + 1]) / (xs[ip] - xs[im]), hz = (P[(jp * nx + i) * 3 + 1] - P[(jm * nx + i) * 3 + 1]) / (zs[jp] - zs[jm]); const l = Math.hypot(hx, 1, hz), k = (j * nx + i) * 3; N[k] = -hx / l; N[k + 1] = 1 / l; N[k + 2] = -hz / l; } // 只上傳改到的那幾排 const vbP = mesh.getVertexBuffer(BABYLON.VertexBuffer.PositionKind), vbN = mesh.getVertexBuffer(BABYLON.VertexBuffer.NormalKind); for (let j = b0; j <= b1; j++) { const s = (j * nx + a0) * 3, e = (j * nx + a1 + 1) * 3; eng.updateDynamicVertexBuffer(vbP.getBuffer(), P.subarray(s, e), s * 4); eng.updateDynamicVertexBuffer(vbN.getBuffer(), N.subarray(s, e), s * 4); } return changed; } // 地形格子上某一點的高度 (雙線性內插,已經包含炸出來的坑) function gridH(G, x, z) { const { xs, zs, nx, nz, P } = G; const i = Math.min(nx - 2, lowerIdx(xs, x)), j = Math.min(nz - 2, lowerIdx(zs, z)); const u = Math.max(0, Math.min(1, (x - xs[i]) / (xs[i + 1] - xs[i]))), v = Math.max(0, Math.min(1, (z - zs[j]) / (zs[j + 1] - zs[j]))); const h = (ii, jj) => P[(jj * nx + ii) * 3 + 1]; return (h(i, j) * (1 - u) + h(i + 1, j) * u) * (1 - v) + (h(i, j + 1) * (1 - u) + h(i + 1, j + 1) * u) * v; } // 貼在地形上的燒焦痕跡:一小片 8×8 格的網格,每個點照地形高度 (很便宜;以前用 CreateDecal 要掃整片地形的三角形) function terrainDecal(G, p) { const S = SCORCH.SIZE, n = 8, ang = Math.random() * 6.28, ca = Math.cos(ang), sa = Math.sin(ang); const pos = [], uv = [], nor = [], idx = []; for (let j = 0; j <= n; j++) for (let i = 0; i <= n; i++) { const lx = (i / n - 0.5) * S, lz = (j / n - 0.5) * S, x = p.x + lx * ca - lz * sa, z = p.z + lx * sa + lz * ca; pos.push(x, gridH(G, x, z) + 0.04, z); uv.push(i / n, j / n); nor.push(0, 1, 0); } for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) { const a = j * (n + 1) + i, b = a + 1, c = a + n + 1, d = c + 1; idx.push(a, c, b, b, c, d); } const m = new BABYLON.Mesh("scorch", scene), vd = new BABYLON.VertexData(); vd.positions = pos; vd.uvs = uv; vd.normals = nor; vd.indices = idx; vd.applyToMesh(m, false); return m; } // 牆壁 / 大的建築網格:一片貼著表面的方形 (CreateDecal 在幾萬個三角形的網格上很慢) function flatDecal(p, n) { const m = BABYLON.MeshBuilder.CreatePlane("scorch", { size: SCORCH.SIZE * 0.75 }, scene); m.position.copyFrom(p).addInPlace(n.scale(0.03)); m.lookAt(p.subtract(n)); // 平面的正面朝 -z:看向表面裡面 = 正面朝外 m.rotate(BABYLON.Axis.Z, Math.random() * 6.28, BABYLON.Space.LOCAL); return m; } return { mark(p) { const hit = scene.pickWithRay(new BABYLON.Ray(new V(p.x, p.y + 1.0, p.z), new V(0, -1, 0), 2.5), envPred); if (!hit || !hit.hit || !hit.pickedMesh) return; const mesh = hit.pickedMesh; this.lastHit = mesh.name; const G = mesh.metadata && mesh.metadata.grid ? gridOf(mesh) : null; if (G && !(opts.isWater && opts.isWater(p.x, p.z))) { try { this.lastCrater = crater(mesh, hit.pickedPoint); } catch (e) { console.warn("crater:", e); } if (opts.clearGrass) opts.clearGrass(p.x, p.z, SCORCH.SIZE * 0.4); } const n = hit.getNormal(true) || new V(0, 1, 0); try { let d; if (G) d = terrainDecal(G, hit.pickedPoint); else if (mesh.getTotalVertices() < 6000) d = BABYLON.MeshBuilder.CreateDecal("scorch", mesh, { position: hit.pickedPoint, normal: n, size: new V(SCORCH.SIZE, SCORCH.SIZE, 2.5), angle: Math.random() * 6.28 }); else d = flatDecal(hit.pickedPoint, n); d.material = mat; d.isPickable = false; d.layerMask = ENV_MASK; d.receiveShadows = true; if (opts.addToScope) opts.addToScope(d); decals.push(d); if (decals.length > SCORCH.MAX) { const old = decals.shift(); if (opts.removeFromScope) opts.removeFromScope(old); old.dispose(); } } catch (e) { console.warn("scorch decal:", e); } }, }; } // ---------- 主體 ---------- // opts: { arms (loadThrowArms 的結果), world (碰撞表), onRelease(), onExplode(point), hud(count) } export function createThrowable(scene, camera, opts) { const V = BABYLON.Vector3; const arms = opts.arms; const counts = { frag: 0, flash: 0, smoke: 0 }; // 各種投擲物剩幾顆 (= 彈掛 / 口袋裡有幾顆,main.js 用 setCounts 同步) let kind = "frag", t = 0, released = false, pinPulled = false, clock = 0; // kind = 現在選的投擲物 frag (破片) / flash (閃光) / smoke (煙霧) const KINDS = ["frag", "flash", "smoke"]; const live = []; // 飛出去的手榴彈 // 每一種投擲物一組網格:proto (樣板)、held (手上那顆)、heldRing (插銷)、heldLever (握片)、pinHand (拔出來掛在左手的插銷) function makeKit(gm, fallback, k) { const proto = gm ? { mesh: gm.body, ring: gm.pin, ringPos: [0, 0, 0], lever: gm.lever } : fallback(scene); proto.mesh.setEnabled(false); if (proto.ring) proto.ring.setEnabled(false); if (proto.lever) proto.lever.setEnabled(false); if (!gm) { try { proto.mesh.material.forceCompilationAsync(proto.mesh); proto.ring.material.forceCompilationAsync(proto.ring); } catch (e) { /* 忽略 */ } } const held = proto.mesh.clone(proto.mesh.name + "Held"); held.layerMask = MAIN_MASK; held.isPickable = false; held.setEnabled(false); held.alwaysSelectAsActiveMesh = true; const heldRing = proto.ring.clone(proto.mesh.name + "Ring"); heldRing.layerMask = MAIN_MASK; heldRing.isPickable = false; heldRing.setEnabled(false); heldRing.alwaysSelectAsActiveMesh = true; if (arms) { held.parent = arms.node(BONES.R.wrist); } heldRing.parent = held; heldRing.position.set(proto.ringPos[0], proto.ringPos[1], proto.ringPos[2]); heldRing.scaling.set(1, 1, 1); let pinHand = null; if (arms && proto.ring) { // 每一幀放在左手食指下面一點 (掛在手指上晃),位置在 applyPose 裡更新 const anchor = new BABYLON.TransformNode("grenadePinAnchor", scene); anchor.parent = arms.holder; pinHand = proto.ring.clone(proto.mesh.name + "PinHand"); pinHand.layerMask = MAIN_MASK; pinHand.isPickable = false; pinHand.alwaysSelectAsActiveMesh = true; proto.ring.refreshBoundingInfo(); const rc = proto.ring.getBoundingInfo().boundingBox.center; pinHand.parent = anchor; pinHand.scaling.setAll(HELD_SCALE * k / 100); pinHand.position.set(-rc.x * HELD_SCALE * k / 100, -rc.y * HELD_SCALE * k / 100, -rc.z * HELD_SCALE * k / 100); pinHand.setEnabled(false); pinHand._anchor = anchor; } let heldLever = null; // 握片 (有模型才有分開的握片) if (proto.lever) { heldLever = proto.lever.clone(proto.mesh.name + "Lever"); heldLever.layerMask = MAIN_MASK; heldLever.isPickable = false; heldLever.alwaysSelectAsActiveMesh = true; heldLever.parent = held; heldLever.position.set(0, 0, 0); heldLever.setEnabled(true); } return { proto, held, heldRing, heldLever, pinHand }; } const kits = { frag: makeKit(opts.grenadeModel, makeGrenadeMesh, SIZE_K.frag), flash: makeKit(opts.flashModel, makeFlashMesh, SIZE_K.flash), smoke: makeKit(opts.smokeModel, makeSmokeMesh, SIZE_K.smoke) }; let proto, held, heldRing, heldLever, pinHand; function useKit(k) { for (const key of Object.keys(kits)) if (key !== k) { const o = kits[key]; o.held.setEnabled(false); if (o.pinHand) o.pinHand.setEnabled(false); } ({ proto, held, heldRing, heldLever, pinHand } = kits[k]); } useKit("frag"); // 手上手榴彈的位置 (右手腕骨頭座標):基本位置 HELD_POS + 在「拿著手榴彈」那個姿勢時、再移 HELD_SHIFT 公分 (換算成手腕骨頭的座標,之後跟著手一起動) let heldFwd = null; function setHeldPos() { if (arms && !heldFwd) { heldFwd = new V(0, 0, 0); try { applyPose([K(0, P_HOLD)], 0); const wr = arms.node(BONES.R.wrist); wr.computeWorldMatrix(true); camera.computeWorldMatrix(true); const sh = camera.getDirection(BABYLON.Axis.X).scale(HELD_SHIFT[0] / 100).add(camera.getDirection(BABYLON.Axis.Y).scale(HELD_SHIFT[1] / 100)).add(camera.getDirection(BABYLON.Axis.Z).scale(HELD_SHIFT[2] / 100)); heldFwd = V.TransformNormal(sh, BABYLON.Matrix.Invert(wr.getWorldMatrix())); } catch (e) { console.warn("held grenade offset:", e); } } held.position.set(HELD_POS[0] + (heldFwd ? heldFwd.x : 0), HELD_POS[1] + (heldFwd ? heldFwd.y : 0), HELD_POS[2] + (heldFwd ? heldFwd.z : 0)); } const cur = () => counts[kind]; const hud = () => { if (opts.hud) opts.hud({ frag: counts.frag, flash: counts.flash, smoke: counts.smoke, kind }); }; // 套用姿勢:keys = 哪一段動畫,time = 第幾秒 let breathe = 0; function applyPose(keys, time) { if (!arms) return; const N = arms.node; const F = (f) => sampleKeys(keys, time, f); // 上半身轉動 (整個手臂跟著轉 = 用身體的力量丟,有重量感) + 呼吸的小晃動 const B = F("B"), br = Math.sin(breathe * 1.7) * 0.004; arms.holder.rotation.set(B[1] + br * 0.5, B[0], B[2]); arms.holder.position.set(ARMS_OFFSET[0], ARMS_OFFSET[1] + B[3] + br, ARMS_OFFSET[2]); camera.computeWorldMatrix(true); const CW = camera.getWorldMatrix(); const HW = (arms.holder.computeWorldMatrix(true), arms.holder.getWorldMatrix()); // 手的位置跟著上半身一起轉 (holder 座標 = 相機座標再加上身體轉動) const rel = BABYLON.Matrix.Translation(-ARMS_OFFSET[0], -ARMS_OFFSET[1], -ARMS_OFFSET[2]).multiply(HW); const toW = (p) => V.TransformCoordinates(new V(p[0], p[1], p[2]), rel); const dirW = (d) => V.TransformNormal(new V(d[0], d[1], d[2]), rel).normalize(); void CW; const tr = F("tr"); for (const side of ["R", "L"]) { const B2 = BONES[side]; const sh = N(B2.arm), el = N(B2.elbow), wr = N(B2.wrist); updateChain(wr); const S = sh.getAbsolutePosition(), Ecur = el.getAbsolutePosition(), Wcur = wr.getAbsolutePosition(); const a = V.Distance(S, Ecur), b = V.Distance(Ecur, Wcur); const tp = F(side).slice(); if (tr > 0) { // 用力時手在抖 (很小、很快) const k = tr * 0.0025, ph = side === "L" ? 0 : 1.7; tp[0] += Math.sin(clock * 47 + ph) * k; tp[1] += Math.sin(clock * 61 + ph * 2) * k; tp[2] += Math.sin(clock * 53 + ph) * k * 0.5; } const T = toW(tp); // IK:手肘位置 let d = T.subtract(S), dl = d.length(); const maxL = (a + b) * 0.999; if (dl > maxL) { d = d.scale(maxL / dl); dl = maxL; } const dn = d.scale(1 / dl); const x = (a * a - b * b + dl * dl) / (2 * dl), h = Math.sqrt(Math.max(0, a * a - x * x)); let pole = dirW(ELBOW_HINT[side]); pole = pole.subtract(dn.scale(V.Dot(pole, dn))).normalize(); const E = S.add(dn.scale(x)).add(pole.scale(h)); const Tt = S.add(d); const bendN = V.Cross(E.subtract(S), Tt.subtract(E)).normalize(); aimNode(sh, el.position, E.subtract(S), new V(1, 0, 0), bendN); aimNode(el, wr.position, Tt.subtract(E), new V(1, 0, 0), bendN); // 手腕:手指方向 + 手掌朝向 aimNode(wr, N(B2.mid).position, dirW(F(side + "f")), arms.palm[side], dirW(F(side + "n"))); // 手指彎曲 const g = F(side + "g"); for (const chain of B2.fingers) chain.forEach((n, i) => { const tn = N(n), r0 = arms.rest.get(n); if (!tn || !r0) return; tn.rotationQuaternion = r0.multiply(BABYLON.Quaternion.RotationAxis(BABYLON.Axis.X, -g * (i === 0 ? 1.05 : 1.25))); }); B2.thumb.forEach((n, i) => { const tn = N(n), r0 = arms.rest.get(n); if (!tn || !r0) return; tn.rotationQuaternion = r0.multiply(BABYLON.Quaternion.RotationAxis(BABYLON.Axis.X, -g * (i === 0 ? 0.3 : 0.7))); }); } // 拔出來的拉環:掛在左手食指和拇指中間下方 if (pinHand && pinHand.isEnabled()) { const a = N(BONES.L.fingers[0][1]), b = N(BONES.L.thumb[2]); updateChain(a); updateChain(b); const wp = a.getAbsolutePosition().add(b.getAbsolutePosition()).scale(0.5); const inv = BABYLON.Matrix.Invert(arms.holder.getWorldMatrix()); const lp = V.TransformCoordinates(wp, inv); pinHand._anchor.position.set(lp.x, lp.y - 0.028, lp.z); pinHand._anchor.rotation.set(0.3 + Math.sin(clock * 6) * 0.15, 0.4, Math.sin(clock * 5) * 0.12); // 一點點晃動 } } function spawnFlying() { const g = proto.mesh.clone("grenadeLive"); g.scaling.setAll(HELD_SCALE * SIZE_K[kind] / 100); g.layerMask = ENV_MASK; g.isPickable = false; g.setEnabled(true); g.parent = null; held.computeWorldMatrix(true); const p = arms ? held.getAbsolutePosition().clone() : camera.position.add(camera.getDirection(BABYLON.Axis.Z).scale(0.5)).add(camera.getDirection(BABYLON.Axis.X).scale(0.15)); // 沒有手臂模型:從鏡頭右前方丟出 const fwd = camera.getDirection(BABYLON.Axis.Z), up = new V(0, 1, 0); // 方向 = 視線 + 一點仰角,再整體往下壓 PITCH_DOWN 度 (水平方向不變,只改上下角度) const d0 = fwd.add(up.scale(GRENADE.UP)).normalize(); const hl = Math.hypot(d0.x, d0.z) || 1e-6, el = Math.atan2(d0.y, hl) - GRENADE.PITCH_DOWN * Math.PI / 180; const hx = hl > 1e-5 ? d0.x / hl : fwd.x, hz = hl > 1e-5 ? d0.z / hl : fwd.z; const v = new V(hx * Math.cos(el), Math.sin(el), hz * Math.cos(el)).scale(GRENADE.SPEED); live.push({ m: g, p, v, spin: new V(Math.random() * 8 - 4, Math.random() * 6, Math.random() * 8 - 4), t0: clock, rest: false, kind }); if (opts.addToScope) opts.addToScope(g); // 握片彈飛 (放手的瞬間,彈簧把握片彈開) if (proto.lever) { const lv = proto.lever.clone("grenadeLeverFly"); lv.scaling.setAll(HELD_SCALE * SIZE_K[kind] / 100); lv.layerMask = ENV_MASK; lv.isPickable = false; lv.setEnabled(true); lv.parent = null; const right = camera.getDirection(BABYLON.Axis.X); const lvV = v.scale(0.35).add(right.scale(2 + Math.random())).add(new V(0, 2.5, 0)); live.push({ m: lv, p: p.add(right.scale(0.03)), v: lvV, spin: new V(Math.random() * 30 - 15, Math.random() * 30 - 15, Math.random() * 30 - 15), t0: clock, rest: false, fuseAt: Infinity, dieAt: clock + 4, small: true }); } } // ---------- 煙霧彈:冒煙 ---------- let smokeTex = null; function startSmoke(gr) { gr.fuseAt = Infinity; const act = live.filter((x) => x.smoke && !x.killNow); for (let k = 0; k <= act.length - SMOKE.MAX; k++) act[k].killNow = true; // 太多團:最舊的收掉 (下一幀處理) smokeTex = smokeTex || getFxTextures(scene); // 噴口位置用一個自己的座標點 (跟著罐子,每幀更新),不綁在罐子的網格上:網格被收掉 / 關掉 / 滾到看不見的地方時,煙才不會跟著不見 const at = gr.p.clone(); at.y += 0.8; // 從罐子上方一點噴 (地面以下的煙會被地形擋掉,抬高一點整團比較完整) const ps = makeSmokePS(scene, "fxSmokeGren", 150, smokeTex.smoke, { mask: ENV_MASK, emitter: at, size: SMOKE.SIZE, grow: 2.2, life: [10, 14], speed: [0.55, SMOKE.SPREAD], slow: 0.2, lift: 0.08, spin: 0.12, color: [0.9, 0.915, 0.93], alpha: SMOKE.ALPHA }); ps.renderingGroupId = 1; // 在半透明的水面之後畫 (河上丟也看得到) // 往四周噴 (水平方向為主,只往上飄一點) = 貼地的一大團,不是衝上天的一柱 ps.createBoxEmitter(new V(-1, 0, -1), new V(1, 0.3, 1), new V(-0.3, 0, -0.3), new V(0.3, 0.2, 0.3)); ps.emitRate = SMOKE.RATE; ps.manualEmitCount = SMOKE.PUFF; gr.smoke = { ps, at, t0: clock, ended: false }; if (opts.onSmoke) opts.onSmoke(gr.p.clone()); } function endSmoke(gr, remove) { if (gr.smoke) { try { gr.smoke.ps.stop(); gr.smoke.ps.dispose(false); } catch (e) { /* 忽略 */ } gr.smoke = null; } // dispose(false) = 不要把共用的煙霧貼圖一起刪掉 (之前會刪掉 → 一團煙收掉後,其他煙 / 之後丟的煙 / 爆炸煙全部變透明) if (remove) { try { gr.m.dispose(); } catch (e) { /* 忽略 */ } const i = live.indexOf(gr); if (i >= 0) live.splice(i, 1); } } // 目前每團煙的位置 / 半徑 (敵人用:視線穿過煙團就看不到) function smokeList() { const out = []; for (const g of live) { if (!g.smoke || g.killNow) continue; const age = clock - g.smoke.t0; const grow = Math.min(1, 0.25 + 0.75 * (age / SMOKE.GROW)); const fade = age > SMOKE.DURATION ? Math.max(0, 1 - (age - SMOKE.DURATION) / 12) : 1; const r = SMOKE.RADIUS * grow * fade; if (r > 0.6) out.push({ x: g.p.x, y: g.p.y + 1, z: g.p.z, r }); } return out; } function explode(gr) { const p = gr.p.clone(); gr.m.dispose(); if (gr.kind === "flash") { if (opts.onFlash) opts.onFlash(p); } else if (opts.onExplode) opts.onExplode(p); } // 飛行 / 彈跳:每一小步用碰撞表的射線檢查有沒有撞到東西 function stepGrenade(gr, dt) { const w = opts.world; const sub = 3, h = dt / sub; for (let s = 0; s < sub; s++) { if (gr.rest) break; gr.v.y -= 9.8 * h; const move = gr.v.scale(h), len = move.length(); if (len < 1e-6) continue; const dir = move.scale(1 / len); let hitT = -1, n = null; if (w && w.rayLong) { hitT = w.rayLong(gr.p.x, gr.p.y, gr.p.z, dir.x, dir.y, dir.z, len + 0.04, false); if (hitT >= 0) { const nn = w.lastNormal(dir.x, dir.y, dir.z); n = new V(nn[0], nn[1], nn[2]); } } if (hitT >= 0) { gr.p.addInPlace(dir.scale(Math.max(0, hitT - 0.04))); const vn = n.scale(V.Dot(gr.v, n)), vt = gr.v.subtract(vn); gr.v = vt.scale(GRENADE.FRICTION).subtract(vn.scale(GRENADE.BOUNCE)); gr.spin.scaleInPlace(0.6); if (opts.onBounce && !gr.small && vn.length() > 2) opts.onBounce(gr.p, Math.min(1, vn.length() / 8)); if (gr.v.length() < 0.6 && n.y > 0.6) { gr.rest = true; gr.v.set(0, 0, 0); } } else gr.p.addInPlace(move); } // 掉出地圖 (保險) if (gr.p.y < -30) gr.rest = true; gr.m.position.copyFrom(gr.p); gr.m.rotation.addInPlace(gr.spin.scale(dt)); } // ---------- 狀態:off (沒拿) → draw (拿出來) → hold (拿著) → pull (拔插銷) → aim (瞄準) → throw (丟) → off // hold → putaway (收起來) → off let state = "off", wantThrow = false, pressQueued = false, releaseQueued = false; const clipEnd = (c) => CLIPS[c][CLIPS[c].length - 1].t; function go(st) { state = st; t = 0; } let empty = false, emptyBob = { x: 0, y: 0 }, emptyRest = false; // 空手模式 (沒帶槍):手臂一直顯示 // 手臂的骨頭全部回到模型原本的樣子 (丟手榴彈的動作會改骨頭的角度) function restPose() { if (!arms) return; for (const [n, q] of arms.rest) { const t = arms.node(n); if (t) t.rotationQuaternion = q.clone(); } } function finish(thrown) { state = "off"; if (arms) { arms.holder.setEnabled(empty); arms.holder.rotation.set(0, 0, 0); } held.setEnabled(false); if (pinHand) pinHand.setEnabled(false); if (opts.onDone) opts.onDone(thrown); } scene.onBeforeRenderObservable.add(() => { const dt = Math.min(0.05, scene.getEngine().getDeltaTime() / 1000); clock += dt; breathe += dt; if (state !== "off") { t += dt; if (state === "draw") { if (t >= clipEnd("draw")) { go("hold"); if (pressQueued) { pressQueued = false; startPull(releaseQueued); releaseQueued = false; } } } else if (state === "pull") { const kp = CLIPS.pull.find((k) => k.pin); if (!pinPulled && t >= kp.t) { pinPulled = true; heldRing.setEnabled(false); if (pinHand) pinHand.setEnabled(true); if (opts.sfx) opts.sfx("pin"); if (opts.onKick) opts.onKick(0.5); // 插銷「啵」一聲拔出來:手 / 畫面頓一下 if (opts.onPin) opts.onPin(); } if (t >= clipEnd("pull")) { go("aim"); } } else if (state === "aim") { if (wantThrow && t > 0.05) go("throw"); } else if (state === "throw") { const kr = CLIPS.throw.find((k) => k.release); if (!released && t >= kr.t) { released = true; held.setEnabled(false); spawnFlying(); const g = live.find((x) => x.m.name === "grenadeLive" && x.fuseAt === undefined); if (g) g.fuseAt = clock + (g.kind === "flash" ? FLASH.FUSE : g.kind === "smoke" ? SMOKE.FUSE : GRENADE.FUSE); // 握片彈開才開始計時 if (opts.onKick) opts.onKick(0.35); if (opts.onRelease) opts.onRelease(); if (opts.sfx) opts.sfx("spoon"); } if (t >= clipEnd("throw")) finish(true); } else if (state === "putaway") { if (t >= clipEnd("putaway")) finish(false); } if (state !== "off") { const keys = state === "hold" ? [K(0, P_HOLD)] : state === "aim" ? [K(0, P_AIM)] : CLIPS[state]; applyPose(keys, state === "hold" || state === "aim" ? 0 : t); } } if (state === "off" && empty && arms) { // 空手:用模型原本的姿勢 (骨頭全部回到預設),整個手臂跟著走路晃 if (!arms.holder.isEnabled() || !emptyRest) { restPose(); emptyRest = true; arms.holder.setEnabled(true); } const b = emptyBob, br = Math.sin(breathe * 1.7) * 0.003; arms.holder.rotation.set(0, 0, 0); arms.holder.position.set(ARMS_OFFSET[0] + EMPTY_OFFSET[0] + b.x, ARMS_OFFSET[1] + EMPTY_OFFSET[1] + b.y + br, ARMS_OFFSET[2] + EMPTY_OFFSET[2]); } else emptyRest = false; for (let i = live.length - 1; i >= 0; i--) { const gr = live[i]; stepGrenade(gr, dt); if (gr.dieAt && clock >= gr.dieAt) { gr.m.dispose(); live.splice(i, 1); continue; } if (gr.smoke) { // 已經在冒煙的煙霧彈:罐子還是會滾 (上面 stepGrenade),煙跟著罐子 if (gr.killNow) { endSmoke(gr, true); continue; } const sm = gr.smoke, age = clock - sm.t0; sm.at.copyFrom(gr.p); sm.at.y += 0.8; if (!sm.ended && age >= SMOKE.DURATION) { sm.ended = true; sm.ps.emitRate = 0; sm.ps.manualEmitCount = 0; } else if (!sm.ended && sm.ps.manualEmitCount === 0) sm.ps.manualEmitCount = -1; // 一開始那一大口噴完 → 改成照每秒的量持續噴 if (age >= SMOKE.DURATION + 16) { endSmoke(gr, true); } continue; } if (clock >= gr.fuseAt) { if (gr.kind === "smoke") { startSmoke(gr); continue; } explode(gr); live.splice(i, 1); } } }); function startPull(throwAfter) { if (cur() <= 0) return; counts[kind]--; if (opts.onUse) opts.onUse(kind); // 拔插銷 = 用掉一顆 (main.js 從彈掛 / 口袋拿掉那個物品) pinPulled = false; released = false; wantThrow = !!throwAfter; go("pull"); hud(); } return { get count() { return counts.frag; }, get flashCount() { return counts.flash; }, get smokeCount() { return counts.smoke; }, get kinds() { return KINDS; }, get kind() { return kind; }, smokes: () => smokeList(), // 目前的煙團 [{x, y, z, r}]:敵人看不穿 clearSmoke: () => { for (const g of live.slice()) if (g.smoke) endSmoke(g, true); }, get current() { return cur(); }, // 切換要丟哪一種 (手上沒拿東西時才可以);回傳現在的種類 setKind(k) { if (state === "off" && KINDS.indexOf(k) >= 0) { kind = k; useKit(k); hud(); } return kind; }, get busy() { return state !== "off"; }, // 空手模式 (沒帶槍):on = 顯示空手手臂;bob(x, y) = 走路的晃動 (公尺) setEmpty(on) { empty = !!on && !!arms; if (!empty && state === "off" && arms) arms.holder.setEnabled(false); }, setBob(x, y) { emptyBob.x = x; emptyBob.y = y; }, get empty() { return empty; }, get state() { return state; }, ready: true, // 手臂模型 (fp_arms.glb) 沒載到也能丟,只是看不到手 hasArms: !!arms, // 拿出手榴彈 (main.js 在槍收起來之後呼叫) equip() { if (state !== "off" || cur() <= 0) return false; useKit(kind); pinPulled = false; released = false; wantThrow = false; pressQueued = false; releaseQueued = false; if (arms) { arms.holder.setEnabled(true); held.setEnabled(true); heldRing.setEnabled(true); if (heldLever) heldLever.setEnabled(true); } if (pinHand) pinHand.setEnabled(false); setHeldPos(); held.rotation.set(0.3, 0, 1.4); { const hs = HELD_SCALE * SIZE_K[kind]; held.scaling.set(hs, hs, hs); } go("draw"); applyPose(CLIPS.draw, 0); if (opts.sfx) opts.sfx("swap"); return true; }, // FIRE 按下 / 放開 press() { if (state === "hold") startPull(false); else if (state === "draw") { pressQueued = true; releaseQueued = false; } }, release() { if (state === "pull" || state === "aim") wantThrow = true; else if (pressQueued) releaseQueued = true; }, // 拿出來的途中就按了一下 = 拿好就直接丟 // 收起來 (還沒拔插銷才可以) putAway() { if (state === "hold" || state === "draw") { go("putaway"); return true; } return false; }, reset() { counts.frag = GRENADE.COUNT; hud(); }, setCounts(f, fl, sm) { if (counts.frag === f && counts.flash === fl && counts.smoke === sm) return; counts.frag = f; counts.flash = fl; counts.smoke = sm; hud(); }, add(n, k) { counts[k && KINDS.indexOf(k) >= 0 ? k : "frag"] += n; hud(); }, // 市場買的 / 撿到的手榴彈 (k = "flash" → 閃光彈、"smoke" → 煙霧彈) // 測試用:停在某段動畫的第 time 秒 preview(clip, time) { if (!arms) return; state = "off"; arms.holder.setEnabled(true); const c = CLIPS[clip] || [K(0, clip === "aim" ? P_AIM : P_HOLD)]; const relT = clip === "throw" ? CLIPS.throw.find((k) => k.release).t : 1e9, pinT = clip === "pull" ? CLIPS.pull.find((k) => k.pin).t : (clip === "aim" || clip === "throw" ? -1 : 1e9); held.setEnabled(time < relT); heldRing.setEnabled(time < pinT); if (pinHand) pinHand.setEnabled(time >= pinT); setHeldPos(); held.rotation.set(0.3, 0, 1.4); { const hs = HELD_SCALE * SIZE_K[kind]; held.scaling.set(hs, hs, hs); } applyPose(c, time); }, }; }