Spaces:
Running
Running
Download engine/engine.js from axonis22/muse-engine-32: direct link, hf CLI and curl.
- Browser
- Download file 219 kB
-
https://huggingface.co/spaces/axonis22/muse-engine-32/resolve/main/engine/engine.js
- Command line
-
hf download hf://spaces/axonis22/muse-engine-32/engine/engine.js
-
curl -L -o engine.js https://huggingface.co/spaces/axonis22/muse-engine-32/resolve/main/engine/engine.js
219 kB
| // Muse Breaker Engine - Phase 29 Combat Feel | |
| // Muse Glimmer - own engine | |
| // Raw WebGL1, no libraries | |
| // Licence: MIT (see LICENSE). The Axonis name and logo are not covered by the MIT licence. | |
| // Muse Engine 29 - Look Picker build: Phase 29 engine + [e29 coach] look/config hooks (search '[e29]'; see muse-authored/FIXES.md) | |
| // Muse Engine 30 - Scene Level: loaded scenes become the level (mesh colliders, units/up-axis), crosshair + pyramid fixes (search '[e30]') | |
| // Muse Engine 32 RC - Save / Load + Model Check (search '[e32]'): setup JSON, drag-and-drop, model check, root-motion strip, perf pass. | |
| // Muse Engine 31 - Animated Characters: skinned GLB/FBX enemies, GPU skinning, clip roles + crossfade, capsule + head/body hitboxes (search '[e31]') | |
| // Phase 29: combat feel — hostile solid AABB (no wall-clip) + fall-then-boom death | |
| // Phase 28: movement pass — land on tops, coyote/buffer jump, step-up/mantle, ladder + parkour route | |
| // Phase 27: Mooring Bollard mesh; Phase 26 NM; Phase 25 TEX; Phase 24 Mitsh/Mitch base | |
| // Phase 21.1 + coach: ammo-in-box pickups; night bulbs self-glow; nearest+farthest lamp pair | |
| // coach: per-axis AABB + height-gated sides + walkable tops | |
| (() => { | |
| const canvas = document.getElementById('muse-canvas'); | |
| const hud = document.getElementById('hud'); | |
| if (hud) hud.textContent = 'Muse Glimmer · Engine 32 RC · Save / Load + Model Check'; // [e31]; [e32] RC label | |
| const creditEl = document.getElementById('credit'); | |
| const gl = canvas.getContext('webgl', { antialias: true }) || canvas.getContext('experimental-webgl'); | |
| if (!gl) { | |
| document.body.innerHTML = '<pre>WebGL not supported</pre>'; | |
| return; | |
| } | |
| function resize() { | |
| const dpr = Math.min(window.devicePixelRatio || 1, 2); | |
| const w = Math.floor(canvas.clientWidth * dpr); | |
| const h = Math.floor(canvas.clientHeight * dpr); | |
| if (canvas.width !== w || canvas.height !== h) { | |
| canvas.width = w; | |
| canvas.height = h; | |
| gl.viewport(0, 0, w, h); | |
| } | |
| } | |
| window.addEventListener('resize', resize); | |
| resize(); | |
| // [e31] GPU skinning palette: 3 vec4 rows per joint (affine 3x4). Keep ~24 vectors for the other vertex uniforms. | |
| const E31_MAXVU = gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS) || 128; | |
| const E31_MAX_BONES = Math.max(8, Math.min(60, Math.floor((E31_MAXVU - 24) / 3), (+((location.search.match(/[?&]maxbones=(\d+)/) || [])[1]) || 60))); // ?maxbones=N simulates a smaller GPU | |
| const E31_SKIN_GLSL = ` | |
| attribute vec4 aSkinIdx; | |
| attribute vec4 aSkinW; | |
| uniform float uSkinOn; | |
| uniform vec4 uBones[${E31_MAX_BONES * 3}]; | |
| void skinRows(out vec4 r0, out vec4 r1, out vec4 r2){ | |
| ivec4 bi = ivec4(aSkinIdx + 0.5) * 3; | |
| r0 = uBones[bi.x] * aSkinW.x + uBones[bi.y] * aSkinW.y + uBones[bi.z] * aSkinW.z + uBones[bi.w] * aSkinW.w; | |
| r1 = uBones[bi.x + 1] * aSkinW.x + uBones[bi.y + 1] * aSkinW.y + uBones[bi.z + 1] * aSkinW.z + uBones[bi.w + 1] * aSkinW.w; | |
| r2 = uBones[bi.x + 2] * aSkinW.x + uBones[bi.y + 2] * aSkinW.y + uBones[bi.z + 2] * aSkinW.z + uBones[bi.w + 2] * aSkinW.w; | |
| } | |
| `; | |
| const vsSource = ` | |
| attribute vec3 aPos; | |
| attribute vec3 aNormal; | |
| attribute vec2 aUV; | |
| attribute vec4 aTangent; | |
| ${E31_SKIN_GLSL} | |
| uniform mat4 uModel; | |
| uniform mat4 uView; | |
| uniform mat4 uProj; | |
| uniform mat4 uLightMVP; | |
| varying vec3 vNormal; | |
| varying vec3 vWorldPos; | |
| varying vec4 vShadowCoord; | |
| varying vec2 vUV; | |
| varying vec3 vT; | |
| varying vec3 vB; | |
| varying vec3 vN; | |
| void main(){ | |
| vec3 sP = aPos; vec3 sN = aNormal; vec3 sT = aTangent.xyz; | |
| if (uSkinOn > 0.5) { // [e31] GPU skinning (4 weights) | |
| vec4 r0; vec4 r1; vec4 r2; skinRows(r0, r1, r2); | |
| vec4 p4 = vec4(aPos, 1.0); | |
| sP = vec3(dot(r0, p4), dot(r1, p4), dot(r2, p4)); | |
| sN = vec3(dot(r0.xyz, aNormal), dot(r1.xyz, aNormal), dot(r2.xyz, aNormal)); | |
| sT = vec3(dot(r0.xyz, aTangent.xyz), dot(r1.xyz, aTangent.xyz), dot(r2.xyz, aTangent.xyz)); | |
| } | |
| vec4 world = uModel * vec4(sP, 1.0); | |
| vWorldPos = world.xyz; | |
| mat3 nMat = mat3(uModel); | |
| vec3 N = normalize(nMat * sN); | |
| vNormal = N; | |
| vN = N; | |
| vec3 T = nMat * sT; | |
| T = normalize(T - N * dot(N, T)); | |
| float tw = aTangent.w == 0.0 ? 1.0 : aTangent.w; | |
| vT = T; | |
| vB = cross(N, T) * tw; | |
| vShadowCoord = uLightMVP * world; | |
| vUV = aUV; | |
| gl_Position = uProj * uView * world; | |
| } | |
| `; | |
| const fsSource = ` | |
| precision mediump float; | |
| varying vec3 vNormal; | |
| varying vec3 vWorldPos; | |
| varying vec4 vShadowCoord; | |
| varying vec2 vUV; | |
| varying vec3 vT; | |
| varying vec3 vB; | |
| varying vec3 vN; | |
| uniform vec3 uLightDir; | |
| uniform float uAmbientAmt; | |
| uniform float uDirAmt; | |
| uniform vec3 uBaseColor; | |
| uniform vec3 uSpecColor; | |
| uniform float uShininess; | |
| uniform vec3 uPointPos; | |
| uniform vec3 uPointColor; | |
| uniform float uPointRadius; | |
| uniform vec3 uPointPos2; | |
| uniform vec3 uPointColor2; | |
| uniform float uPointRadius2; | |
| uniform float uAlpha; | |
| uniform sampler2D uShadowMap; | |
| uniform float uShadowBias; | |
| uniform float uUseShadow; | |
| uniform vec3 uCamPos; | |
| uniform vec3 uFogColor; | |
| uniform float uFogNear; | |
| uniform float uFogFar; | |
| uniform sampler2D uAlbedo; | |
| uniform float uUseTex; | |
| uniform sampler2D uNormalMap; | |
| uniform float uHasNormalMap; | |
| uniform float uNormalScale; | |
| void main(){ | |
| vec3 N = normalize(vNormal); | |
| if (uHasNormalMap > 0.5) { | |
| vec3 mapN = texture2D(uNormalMap, vUV).xyz * 2.0 - 1.0; | |
| mapN.xy *= uNormalScale; | |
| mapN = normalize(mapN); | |
| vec3 T = normalize(vT); | |
| vec3 B = normalize(vB); | |
| vec3 Nn = normalize(vN); | |
| N = normalize(mat3(T, B, Nn) * mapN); | |
| } | |
| vec3 Ldir = normalize(uLightDir); | |
| float diffDir = max(dot(N, Ldir), 0.0); | |
| vec3 colDir = uBaseColor * uAmbientAmt; | |
| vec3 shadowCoord = vShadowCoord.xyz / vShadowCoord.w; | |
| shadowCoord = shadowCoord * 0.5 + 0.5; | |
| float shadow = 1.0; | |
| if (uUseShadow > 0.5 && shadowCoord.x >= 0.0 && shadowCoord.x <= 1.0 && shadowCoord.y >= 0.0 && shadowCoord.y <= 1.0 && shadowCoord.z >= 0.0 && shadowCoord.z <= 1.0) { | |
| float closest = texture2D(uShadowMap, shadowCoord.xy).r; | |
| float currentDepth = shadowCoord.z - uShadowBias; | |
| float s0 = (currentDepth <= closest) ? 1.0 : 0.35; | |
| float texel = 1.0 / 1024.0; | |
| float s1 = (currentDepth <= texture2D(uShadowMap, shadowCoord.xy + vec2(texel, 0.0)).r) ? 1.0 : 0.35; | |
| float s2 = (currentDepth <= texture2D(uShadowMap, shadowCoord.xy + vec2(0.0, texel)).r) ? 1.0 : 0.35; | |
| shadow = (s0 + s1 + s2) / 3.0; | |
| } | |
| colDir += uBaseColor * uDirAmt * diffDir * shadow; | |
| vec3 toLight = uPointPos - vWorldPos; | |
| float dist = length(toLight); | |
| float att = 1.0 / (1.0 + 0.09*dist + 0.032*dist*dist); | |
| float pointFactor = 0.0; | |
| if (dist < uPointRadius) { | |
| vec3 Lpoint = normalize(toLight); | |
| pointFactor = max(dot(N, Lpoint), 0.0) * att; | |
| } | |
| vec3 colPoint = uPointColor * pointFactor; | |
| vec3 toLight2 = uPointPos2 - vWorldPos; | |
| float dist2 = length(toLight2); | |
| float att2 = 1.0 / (1.0 + 0.09*dist2 + 0.032*dist2*dist2); | |
| float pointFactor2 = 0.0; | |
| if (dist2 < uPointRadius2) { | |
| vec3 Lpoint2 = normalize(toLight2); | |
| pointFactor2 = max(dot(N, Lpoint2), 0.0) * att2; | |
| } | |
| vec3 colPoint2 = uPointColor2 * pointFactor2; | |
| vec3 col = colDir + colPoint + colPoint2; | |
| vec3 V = normalize(-vWorldPos + uCamPos); | |
| vec3 H = normalize(Ldir + V); | |
| float spec = pow(max(dot(N, H), 0.0), uShininess); | |
| col += uSpecColor * spec * 0.6; | |
| if (uUseTex > 0.5) { | |
| vec3 tex = texture2D(uAlbedo, vUV).rgb; | |
| col *= tex; | |
| } | |
| float fogDist = length(vWorldPos - uCamPos); | |
| float fogFactor = clamp((fogDist - uFogNear) / (uFogFar - uFogNear), 0.0, 1.0); | |
| col = mix(col, uFogColor, fogFactor); | |
| gl_FragColor = vec4(col, uAlpha); | |
| } | |
| `; | |
| const vsDepth = ` | |
| attribute vec3 aPos; | |
| ${E31_SKIN_GLSL} | |
| uniform mat4 uModel; | |
| uniform mat4 uLightVP; | |
| void main(){ | |
| vec3 sP = aPos; | |
| if (uSkinOn > 0.5) { vec4 r0; vec4 r1; vec4 r2; skinRows(r0, r1, r2); vec4 p4 = vec4(aPos, 1.0); sP = vec3(dot(r0, p4), dot(r1, p4), dot(r2, p4)); } // [e31] | |
| gl_Position = uLightVP * uModel * vec4(sP, 1.0); | |
| } | |
| `; | |
| const fsDepth = ` | |
| precision mediump float; | |
| void main(){ gl_FragColor = vec4(0.0,0.0,0.0,1.0); } | |
| `; | |
| function compile(type, source) { | |
| const s = gl.createShader(type); | |
| gl.shaderSource(s, source); | |
| gl.compileShader(s); | |
| if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { | |
| console.error(gl.getShaderInfoLog(s)); | |
| } | |
| return s; | |
| } | |
| // [e31] shader variants: the skin branch is compiled out of the plain programs (a uniform branch still costs every vertex on | |
| // SwiftShader / low-end GPUs) and compiled in unconditionally for the skinned variants used only by skinned character parts. | |
| function e31Variant(src, skinned){ return src.split('if (uSkinOn > 0.5) {').join(skinned ? 'if (true) {' : 'if (false) {'); } | |
| const program = gl.createProgram(); | |
| gl.attachShader(program, compile(gl.VERTEX_SHADER, e31Variant(vsSource, false))); // [e31] plain variant | |
| gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fsSource)); | |
| gl.bindAttribLocation(program, 0, 'aPos'); // [e31] keep attribute 0 = always-enabled position (skin attributes are optional) | |
| gl.linkProgram(program); | |
| if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { | |
| console.error(gl.getProgramInfoLog(program)); | |
| } | |
| const depthProgram = gl.createProgram(); | |
| gl.attachShader(depthProgram, compile(gl.VERTEX_SHADER, e31Variant(vsDepth, false))); // [e31] plain variant | |
| gl.attachShader(depthProgram, compile(gl.FRAGMENT_SHADER, fsDepth)); | |
| gl.bindAttribLocation(depthProgram, 0, 'aPos'); // [e31] | |
| gl.linkProgram(depthProgram); | |
| // [e31] uniform mirror: the engine keeps setting uniforms through the plain-program locations. While a skinned variant is | |
| // current those calls are translated to it, and each variant is brought up to date (and back) when it is switched in. | |
| const E31U = (function(){ | |
| const info = new Map(), plainLoc = new Map(), recs = new Map(), baseOf = new Map(), skinLoc = new Map(), applied = new Map(), dirty = new Map(); | |
| const _gul = gl.getUniformLocation.bind(gl), _use = gl.useProgram.bind(gl), orig = {}; | |
| let cur = null, ver = 0; | |
| gl.getUniformLocation = function(p, name){ | |
| const l = _gul(p, name); | |
| if (l && (p === program || p === depthProgram)) { info.set(l, { base: p, name: name }); if (!plainLoc.has(p)) plainLoc.set(p, new Map()); plainLoc.get(p).set(name, l); } | |
| return l; | |
| }; | |
| function locIn(skin, name){ const m = skinLoc.get(skin); if (!m.has(name)) m.set(name, _gul(skin, name)); return m.get(name); } | |
| function call(l, r){ if (!l || !r) return; const f = orig[r.fn]; if (r.mat) f(l, r.a, r.buf); else if (r.vec) f(l, r.buf); else f(l, r.a, r.b, r.c, r.d); } | |
| ['uniform1f', 'uniform2f', 'uniform3f', 'uniform4f', 'uniform1i', 'uniform1fv', 'uniform2fv', 'uniform3fv', 'uniform4fv', 'uniformMatrix3fv', 'uniformMatrix4fv'].forEach(function(fn){ | |
| const o = orig[fn] = gl[fn].bind(gl), mat = fn.indexOf('Matrix') >= 0, vec = !mat && fn.slice(-1) === 'v'; | |
| gl[fn] = function(loc, a, b, c, d){ | |
| const I = loc ? info.get(loc) : null; | |
| if (!I) return o(loc, a, b, c, d); | |
| let R = recs.get(I.base); if (!R) { R = new Map(); recs.set(I.base, R); } | |
| let r = R.get(I.name); if (!r) { r = { fn: fn, mat: mat, vec: vec, a: 0, b: 0, c: 0, d: 0, buf: null, ver: 0 }; R.set(I.name, r); } | |
| r.fn = fn; r.mat = mat; r.vec = vec; r.ver = ++ver; | |
| if (mat || vec) { const src = mat ? b : a; if (!r.buf || r.buf.length !== src.length) r.buf = new Float32Array(src.length); r.buf.set(src); r.a = a; } | |
| else { r.a = a; r.b = b; r.c = c; r.d = d; } | |
| if (baseOf.get(cur) === I.base) { call(locIn(cur, I.name), r); applied.get(cur).set(I.name, r.ver); dirty.get(I.base).add(I.name); return; } | |
| return o(loc, a, b, c, d); | |
| }; | |
| }); | |
| gl.useProgram = function(p){ | |
| _use(p); cur = p; | |
| const base = baseOf.get(p); | |
| if (base) { const R = recs.get(base), A = applied.get(p); if (R) R.forEach(function(r, name){ if ((A.get(name) || 0) < r.ver) { call(locIn(p, name), r); A.set(name, r.ver); } }); } | |
| else if (dirty.has(p) && dirty.get(p).size) { const R = recs.get(p), L = plainLoc.get(p) || new Map(); dirty.get(p).forEach(function(name){ call(L.get(name), R.get(name)); }); dirty.get(p).clear(); } | |
| }; | |
| return { register: function(skin, base){ baseOf.set(skin, base); skinLoc.set(skin, new Map()); applied.set(skin, new Map()); if (!dirty.has(base)) dirty.set(base, new Set()); } }; | |
| })(); | |
| const aPos = gl.getAttribLocation(program, 'aPos'); | |
| const aNormal = gl.getAttribLocation(program, 'aNormal'); | |
| const aUV = gl.getAttribLocation(program, 'aUV'); | |
| const uModel = gl.getUniformLocation(program, 'uModel'); | |
| const uView = gl.getUniformLocation(program, 'uView'); | |
| const uProj = gl.getUniformLocation(program, 'uProj'); | |
| const uLightMVP = gl.getUniformLocation(program, 'uLightMVP'); | |
| const uLightDir = gl.getUniformLocation(program, 'uLightDir'); | |
| const uAmbientAmt = gl.getUniformLocation(program, 'uAmbientAmt'); | |
| const uDirAmt = gl.getUniformLocation(program, 'uDirAmt'); | |
| const uBaseColor = gl.getUniformLocation(program, 'uBaseColor'); | |
| const uSpecColor = gl.getUniformLocation(program, 'uSpecColor'); | |
| const uShininess = gl.getUniformLocation(program, 'uShininess'); | |
| const uPointPos = gl.getUniformLocation(program, 'uPointPos'); | |
| const uPointColor = gl.getUniformLocation(program, 'uPointColor'); | |
| const uPointRadius = gl.getUniformLocation(program, 'uPointRadius'); | |
| const uPointPos2 = gl.getUniformLocation(program, 'uPointPos2'); | |
| const uPointColor2 = gl.getUniformLocation(program, 'uPointColor2'); | |
| const uPointRadius2 = gl.getUniformLocation(program, 'uPointRadius2'); | |
| const uAlpha = gl.getUniformLocation(program, 'uAlpha'); | |
| const uShadowMap = gl.getUniformLocation(program, 'uShadowMap'); | |
| const uShadowBias = gl.getUniformLocation(program, 'uShadowBias'); | |
| const uUseShadow = gl.getUniformLocation(program, 'uUseShadow'); | |
| const uCamPos = gl.getUniformLocation(program, 'uCamPos'); | |
| const uFogColor = gl.getUniformLocation(program, 'uFogColor'); | |
| const uFogNear = gl.getUniformLocation(program, 'uFogNear'); | |
| const uFogFar = gl.getUniformLocation(program, 'uFogFar'); | |
| const uAlbedo = gl.getUniformLocation(program, 'uAlbedo'); | |
| const uUseTex = gl.getUniformLocation(program, 'uUseTex'); | |
| const aTangent = gl.getAttribLocation(program, 'aTangent'); | |
| const uNormalMap = gl.getUniformLocation(program, 'uNormalMap'); | |
| const uHasNormalMap = gl.getUniformLocation(program, 'uHasNormalMap'); | |
| const uNormalScale = gl.getUniformLocation(program, 'uNormalScale'); | |
| const d_aPos = gl.getAttribLocation(depthProgram, 'aPos'); | |
| const d_uModel = gl.getUniformLocation(depthProgram, 'uModel'); | |
| const d_uLightVP = gl.getUniformLocation(depthProgram, 'uLightVP'); | |
| // [e31] skinned variants: same attribute slots as the plain programs, skin attributes on the next two free slots | |
| const E31_SKIN_SLOTS = (function(){ let m = 0; [aPos, aNormal, aUV, aTangent].forEach(function(l){ if (l > m) m = l; }); return [m + 1, m + 2]; })(); | |
| function e31LinkSkin(vs, fs, binds){ const p = gl.createProgram(); gl.attachShader(p, compile(gl.VERTEX_SHADER, e31Variant(vs, true))); gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fs)); | |
| binds.forEach(function(b){ if (b[1] >= 0) gl.bindAttribLocation(p, b[1], b[0]); }); gl.bindAttribLocation(p, E31_SKIN_SLOTS[0], 'aSkinIdx'); gl.bindAttribLocation(p, E31_SKIN_SLOTS[1], 'aSkinW'); | |
| gl.linkProgram(p); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) console.error(gl.getProgramInfoLog(p)); return p; } | |
| const skinProgram = e31LinkSkin(vsSource, fsSource, [['aPos', aPos], ['aNormal', aNormal], ['aUV', aUV], ['aTangent', aTangent]]); | |
| const skinDepthProgram = e31LinkSkin(vsDepth, fsDepth, [['aPos', d_aPos]]); | |
| E31U.register(skinProgram, program); E31U.register(skinDepthProgram, depthProgram); | |
| const aSkinIdx = E31_SKIN_SLOTS[0], aSkinW = E31_SKIN_SLOTS[1], d_aSkinIdx = E31_SKIN_SLOTS[0], d_aSkinW = E31_SKIN_SLOTS[1]; | |
| const uBones = gl.getUniformLocation(skinProgram, 'uBones'), d_uBones = gl.getUniformLocation(skinDepthProgram, 'uBones'); | |
| gl.enable(gl.DEPTH_TEST); | |
| const depthExt = gl.getExtension('WEBGL_depth_texture'); | |
| let useShadowMap = !!depthExt; | |
| const SHADOW_SIZE = 1024; | |
| let shadowTex = null, shadowFBO = null; | |
| let shadowColor = null; | |
| if (useShadowMap) { | |
| shadowTex = gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D, shadowTex); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT, SHADOW_SIZE, SHADOW_SIZE, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | |
| shadowColor = gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D, shadowColor); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, SHADOW_SIZE, SHADOW_SIZE, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | |
| shadowFBO = gl.createFramebuffer(); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, shadowFBO); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, shadowColor, 0); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, shadowTex, 0); | |
| let status = gl.checkFramebufferStatus(gl.FRAMEBUFFER); | |
| if (status !== gl.FRAMEBUFFER_COMPLETE) { | |
| gl.bindTexture(gl.TEXTURE_2D, shadowTex); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT, SHADOW_SIZE, SHADOW_SIZE, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_SHORT, null); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, shadowTex, 0); | |
| status = gl.checkFramebufferStatus(gl.FRAMEBUFFER); | |
| } | |
| if (status !== gl.FRAMEBUFFER_COMPLETE) { | |
| console.warn('Shadow FBO incomplete, falling back to blob shadows'); | |
| useShadowMap = false; | |
| } | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, null); | |
| } | |
| const albedoTex = gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D, albedoTex); | |
| const sizeTex = 256; | |
| const canvasTex = document.createElement('canvas'); | |
| canvasTex.width = sizeTex; | |
| canvasTex.height = sizeTex; | |
| const ctx = canvasTex.getContext('2d'); | |
| const imgData = ctx.createImageData(sizeTex, sizeTex); | |
| const d = imgData.data; | |
| const tile = 32; | |
| for (let y=0; y<sizeTex; y++) { | |
| for (let x=0; x<sizeTex; x++) { | |
| const i = (y*sizeTex + x)*4; | |
| const cx = Math.floor(x/tile)%2; | |
| const cy = Math.floor(y/tile)%2; | |
| const edge = ((x%tile)<2 || (y%tile)<2) ? 1 : 0; | |
| const v = (cx ^ cy) ? 210 : 95; | |
| const r = v + edge*25; | |
| const g = (v*0.85) + edge*18; | |
| const b = (v*0.55) + edge*10; | |
| d[i]=Math.min(255,r|0); d[i+1]=Math.min(255,g|0); d[i+2]=Math.min(255,b|0); d[i+3]=255; | |
| } | |
| } | |
| ctx.putImageData(imgData,0,0); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvasTex); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); | |
| // Flat tangent-space normal (0.5,0.5,1) when material has no normalTexture | |
| const flatNormalTex = gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D, flatNormalTex); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([128,128,255,255])); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); | |
| function createMesh(positions, normals, uvs) { | |
| const vertCount = positions.length / 3; | |
| const data = new Float32Array(vertCount * 8); | |
| for (let i = 0; i < vertCount; i++) { | |
| data[i*8+0] = positions[i*3+0]; | |
| data[i*8+1] = positions[i*3+1]; | |
| data[i*8+2] = positions[i*3+2]; | |
| data[i*8+3] = normals[i*3+0]; | |
| data[i*8+4] = normals[i*3+1]; | |
| data[i*8+5] = normals[i*3+2]; | |
| if (uvs && uvs.length >= (i+1)*2) { | |
| data[i*8+6] = uvs[i*2+0]; | |
| data[i*8+7] = uvs[i*2+1]; | |
| } else { | |
| data[i*8+6] = positions[i*3+0] * 0.4; | |
| data[i*8+7] = positions[i*3+2] * 0.4; | |
| } | |
| } | |
| const vbo = gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, vbo); | |
| gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); | |
| return { vbo, count: vertCount, stride: 32, hasTangent: false }; | |
| } | |
| // Phase 26: pos3+norm3+uv2+tan4 (xyz + handedness w) | |
| function createMeshWithTangents(positions, normals, uvs, tangents) { | |
| const vertCount = positions.length / 3; | |
| const data = new Float32Array(vertCount * 12); | |
| for (let i = 0; i < vertCount; i++) { | |
| const o = i * 12; | |
| data[o+0] = positions[i*3+0]; | |
| data[o+1] = positions[i*3+1]; | |
| data[o+2] = positions[i*3+2]; | |
| data[o+3] = normals[i*3+0]; | |
| data[o+4] = normals[i*3+1]; | |
| data[o+5] = normals[i*3+2]; | |
| if (uvs && uvs.length >= (i+1)*2) { | |
| data[o+6] = uvs[i*2+0]; | |
| data[o+7] = uvs[i*2+1]; | |
| } else { | |
| data[o+6] = positions[i*3+0] * 0.4; | |
| data[o+7] = positions[i*3+2] * 0.4; | |
| } | |
| if (tangents && tangents.length >= (i+1)*4) { | |
| data[o+8] = tangents[i*4+0]; | |
| data[o+9] = tangents[i*4+1]; | |
| data[o+10] = tangents[i*4+2]; | |
| data[o+11] = tangents[i*4+3]; | |
| } else { | |
| data[o+8] = 1; data[o+9] = 0; data[o+10] = 0; data[o+11] = 1; | |
| } | |
| } | |
| const vbo = gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, vbo); | |
| gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); | |
| return { vbo, count: vertCount, stride: 48, hasTangent: true }; | |
| } | |
| /** Build UV-based tangents (WebGL1-stable; no OES_standard_derivatives required). */ | |
| function computeTangentsFromUV(posOut, normOut, uvOut) { | |
| const nVert = posOut.length / 3; | |
| const tan1 = new Float32Array(nVert * 3); | |
| const tan2 = new Float32Array(nVert * 3); | |
| for (let i = 0; i < nVert; i += 3) { | |
| const i0 = i, i1 = i + 1, i2 = i + 2; | |
| const x0 = posOut[i0*3], y0 = posOut[i0*3+1], z0 = posOut[i0*3+2]; | |
| const x1 = posOut[i1*3], y1 = posOut[i1*3+1], z1 = posOut[i1*3+2]; | |
| const x2 = posOut[i2*3], y2 = posOut[i2*3+1], z2 = posOut[i2*3+2]; | |
| const u0 = uvOut[i0*2], v0 = uvOut[i0*2+1]; | |
| const u1 = uvOut[i1*2], v1 = uvOut[i1*2+1]; | |
| const u2 = uvOut[i2*2], v2 = uvOut[i2*2+1]; | |
| const ex1 = x1 - x0, ey1 = y1 - y0, ez1 = z1 - z0; | |
| const ex2 = x2 - x0, ey2 = y2 - y0, ez2 = z2 - z0; | |
| const du1 = u1 - u0, dv1 = v1 - v0; | |
| const du2 = u2 - u0, dv2 = v2 - v0; | |
| const det = du1 * dv2 - du2 * dv1; | |
| const r = Math.abs(det) > 1e-8 ? 1.0 / det : 0.0; | |
| const tx = (ex1 * dv2 - ex2 * dv1) * r; | |
| const ty = (ey1 * dv2 - ey2 * dv1) * r; | |
| const tz = (ez1 * dv2 - ez2 * dv1) * r; | |
| const bx = (ex2 * du1 - ex1 * du2) * r; | |
| const by = (ey2 * du1 - ey1 * du2) * r; | |
| const bz = (ez2 * du1 - ez1 * du2) * r; | |
| for (const vi of [i0, i1, i2]) { | |
| tan1[vi*3] += tx; tan1[vi*3+1] += ty; tan1[vi*3+2] += tz; | |
| tan2[vi*3] += bx; tan2[vi*3+1] += by; tan2[vi*3+2] += bz; | |
| } | |
| } | |
| const out = new Float32Array(nVert * 4); | |
| for (let i = 0; i < nVert; i++) { | |
| let nx = normOut[i*3], ny = normOut[i*3+1], nz = normOut[i*3+2]; | |
| let tx = tan1[i*3], ty = tan1[i*3+1], tz = tan1[i*3+2]; | |
| const ndott = nx*tx + ny*ty + nz*tz; | |
| tx -= nx * ndott; ty -= ny * ndott; tz -= nz * ndott; | |
| let len = Math.hypot(tx, ty, tz); | |
| if (len < 1e-8) { | |
| // stable fallback: pick axis least aligned with N | |
| if (Math.abs(nx) < 0.9) { tx = 1; ty = 0; tz = 0; } | |
| else { tx = 0; ty = 0; tz = 1; } | |
| const nd = nx*tx + ny*ty + nz*tz; | |
| tx -= nx * nd; ty -= ny * nd; tz -= nz * nd; | |
| len = Math.hypot(tx, ty, tz) || 1; | |
| } | |
| tx /= len; ty /= len; tz /= len; | |
| const bx = tan2[i*3], by = tan2[i*3+1], bz = tan2[i*3+2]; | |
| const cx = ny*tz - nz*ty, cy = nz*tx - nx*tz, cz = nx*ty - ny*tx; | |
| const w = (cx*bx + cy*by + cz*bz) < 0 ? -1 : 1; | |
| out[i*4] = tx; out[i*4+1] = ty; out[i*4+2] = tz; out[i*4+3] = w; | |
| } | |
| return out; | |
| } | |
| function bindDefaultTangent() { | |
| if (aTangent < 0) return; | |
| gl.disableVertexAttribArray(aTangent); | |
| gl.vertexAttrib4f(aTangent, 1, 0, 0, 1); | |
| } | |
| function clearNormalMapUniforms() { | |
| gl.uniform1f(uHasNormalMap, 0.0); | |
| gl.uniform1f(uNormalScale, 1.0); | |
| gl.activeTexture(gl.TEXTURE2); | |
| gl.bindTexture(gl.TEXTURE_2D, flatNormalTex); | |
| gl.uniform1i(uNormalMap, 2); | |
| } | |
| const OBJ_PYRAMID = ` | |
| v -1.2 0 -1.2 | |
| v 1.2 0 -1.2 | |
| v 1.2 0 1.2 | |
| v -1.2 0 1.2 | |
| v 0 1.8 0 | |
| f 1 2 5 | |
| f 2 3 5 | |
| f 3 4 5 | |
| f 4 1 5 | |
| f 1 4 3 | |
| f 1 3 2 | |
| `; | |
| const OBJ_ROCK = ` | |
| v -0.6 0 -0.5 | |
| v 0.6 0 -0.5 | |
| v 0.5 0 0.6 | |
| v -0.5 0 0.6 | |
| v 0 0.9 0 | |
| v -0.4 0.4 -0.3 | |
| v 0.4 0.4 -0.3 | |
| v 0.3 0.4 0.4 | |
| v -0.3 0.4 0.4 | |
| f 1 2 5 | |
| f 2 3 5 | |
| f 3 4 5 | |
| f 4 1 5 | |
| f 1 6 5 | |
| f 2 7 5 | |
| f 3 8 5 | |
| f 4 9 5 | |
| f 6 7 2 | |
| f 7 8 3 | |
| f 8 9 4 | |
| f 9 6 1 | |
| `; | |
| function parseOBJ(objText) { | |
| const verts = []; | |
| const faces = []; | |
| const lines = objText.split('\n'); | |
| for (let line of lines) { | |
| line = line.trim(); | |
| if (!line || line.startsWith('#')) continue; | |
| const p = line.split(/\s+/); | |
| if (p[0] === 'v') { | |
| verts.push(parseFloat(p[1]), parseFloat(p[2]), parseFloat(p[3])); | |
| } else if (p[0] === 'f') { | |
| const idx = p.slice(1).map(s => parseInt(s.split('/')[0],10)-1); | |
| for (let i=1;i<idx.length-1;i++) { | |
| faces.push(idx[0], idx[i], idx[i+1]); | |
| } | |
| } | |
| } | |
| const pos = []; | |
| const norm = []; | |
| for (let i=0;i<faces.length;i+=3) { | |
| const i0 = faces[i]*3, i1 = faces[i+1]*3, i2 = faces[i+2]*3; | |
| const x0=verts[i0], y0=verts[i0+1], z0=verts[i0+2]; | |
| const x1=verts[i1], y1=verts[i1+1], z1=verts[i1+2]; | |
| const x2=verts[i2], y2=verts[i2+1], z2=verts[i2+2]; | |
| pos.push(x0,y0,z0, x1,y1,z1, x2,y2,z2); | |
| const ux = x1-x0, uy = y1-y0, uz = z1-z0; | |
| const vx = x2-x0, vy = y2-y0, vz = z2-z0; | |
| let nx = uy*vz - uz*vy; | |
| let ny = uz*vx - ux*vz; | |
| let nz = ux*vy - uy*vx; | |
| const len = Math.hypot(nx,ny,nz) || 1; | |
| nx/=len; ny/=len; nz/=len; | |
| norm.push(nx,ny,nz, nx,ny,nz, nx,ny,nz); | |
| } | |
| return createMesh(new Float32Array(pos), new Float32Array(norm)); | |
| } | |
| const objMesh = parseOBJ(OBJ_PYRAMID); | |
| const objRock = parseOBJ(OBJ_ROCK); | |
| function meshBoundsFromPositions(positions) { | |
| let minX=Infinity,minY=Infinity,minZ=Infinity,maxX=-Infinity,maxY=-Infinity,maxZ=-Infinity; | |
| for (let i = 0; i < positions.length; i += 3) { | |
| const x=positions[i], y=positions[i+1], z=positions[i+2]; | |
| if (x<minX)minX=x; if (y<minY)minY=y; if (z<minZ)minZ=z; | |
| if (x>maxX)maxX=x; if (y>maxY)maxY=y; if (z>maxZ)maxZ=z; | |
| } | |
| return { min:[minX,minY,minZ], max:[maxX,maxY,maxZ], size:[maxX-minX, maxY-minY, maxZ-minZ] }; | |
| } | |
| let glbLastBounds = { min:[0,0,0], max:[1,1,1], size:[1,1,1] }; | |
| // Phase 25/26: multi-mesh GLB + TEXCOORD_0 + baseColor + normalTexture (WebGL1) | |
| const whiteTex = gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D, whiteTex); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255,255,255,255])); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); | |
| function mat4FromTRS(t, r, s) { | |
| let m = identity(); | |
| if (t) { m[12] = t[0]; m[13] = t[1]; m[14] = t[2]; } | |
| if (r) { | |
| const x = r[0], y = r[1], z = r[2], w = r[3]; | |
| const x2 = x + x, y2 = y + y, z2 = z + z; | |
| const xx = x * x2, xy = x * y2, xz = x * z2; | |
| const yy = y * y2, yz = y * z2, zz = z * z2; | |
| const wx = w * x2, wy = w * y2, wz = w * z2; | |
| const q = identity(); | |
| q[0] = 1 - (yy + zz); q[1] = xy + wz; q[2] = xz - wy; | |
| q[4] = xy - wz; q[5] = 1 - (xx + zz); q[6] = yz + wx; | |
| q[8] = xz + wy; q[9] = yz - wx; q[10] = 1 - (xx + yy); | |
| m = multiply(m, q); | |
| } | |
| if (s) { | |
| const sx = s[0], sy = s[1], sz = s[2]; | |
| const sm = identity(); | |
| sm[0]=sx; sm[5]=sy; sm[10]=sz; | |
| m = multiply(m, sm); | |
| } | |
| return m; | |
| } | |
| function nodeLocalMatrix(node) { | |
| if (node.matrix && node.matrix.length === 16) return new Float32Array(node.matrix); | |
| return mat4FromTRS(node.translation, node.rotation, node.scale); | |
| } | |
| function transformPoint(m, x, y, z) { | |
| return [ | |
| m[0]*x + m[4]*y + m[8]*z + m[12], | |
| m[1]*x + m[5]*y + m[9]*z + m[13], | |
| m[2]*x + m[6]*y + m[10]*z + m[14] | |
| ]; | |
| } | |
| function loadImageTextureFromUrl(url) { | |
| return new Promise(function (resolve, reject) { | |
| const img = new Image(); | |
| img.onload = function () { | |
| const tex = gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D, tex); | |
| gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); | |
| resolve(tex); | |
| }; | |
| img.onerror = function () { reject(new Error('Texture load failed')); }; | |
| img.src = url; | |
| }); | |
| } | |
| function loadEmbeddedImageTexture(gltf, binBytes, imageIndex) { | |
| const image = gltf.images[imageIndex]; | |
| if (image.uri && image.uri.indexOf('data:') === 0) return loadImageTextureFromUrl(image.uri); | |
| if (image.bufferView == null) return Promise.reject(new Error('Image has no bufferView/uri')); | |
| const view = gltf.bufferViews[image.bufferView]; | |
| const start = view.byteOffset || 0; | |
| const slice = binBytes.slice(start, start + view.byteLength); | |
| const mime = image.mimeType || 'image/png'; | |
| const blob = new Blob([slice], { type: mime }); | |
| const url = URL.createObjectURL(blob); | |
| return loadImageTextureFromUrl(url).then(function (tex) { | |
| URL.revokeObjectURL(url); | |
| return tex; | |
| }); | |
| } | |
| function parseGLBChunks(arrayBuffer) { | |
| const dv = new DataView(arrayBuffer); | |
| if (arrayBuffer.byteLength < 12) throw new Error('GLB too short'); | |
| if (dv.getUint32(0, true) !== 0x46546C67) throw new Error('Not GLB (bad magic)'); | |
| if (dv.getUint32(4, true) !== 2) throw new Error('Need GLB version 2'); | |
| const totalLen = dv.getUint32(8, true); | |
| let offset = 12; | |
| let json = null; | |
| let binStart = 0; | |
| let binLen = 0; | |
| while (offset + 8 <= arrayBuffer.byteLength && offset < totalLen) { | |
| const chunkLen = dv.getUint32(offset, true); | |
| const chunkType = dv.getUint32(offset + 4, true); | |
| offset += 8; | |
| if (offset + chunkLen > arrayBuffer.byteLength) throw new Error('GLB chunk overrun'); | |
| if (chunkType === 0x4E4F534A) { | |
| const bytes = new Uint8Array(arrayBuffer, offset, chunkLen); | |
| let end = chunkLen; | |
| while (end > 0 && bytes[end - 1] === 0x20) end--; | |
| json = JSON.parse(new TextDecoder('utf-8').decode(bytes.subarray(0, end))); | |
| } else if (chunkType === 0x004E4942) { | |
| binStart = offset; | |
| binLen = chunkLen; | |
| } | |
| offset += chunkLen; | |
| } | |
| if (!json) throw new Error('No JSON chunk'); | |
| const binBytes = arrayBuffer.slice(binStart, binStart + binLen); | |
| return { json, binBytes, binStart, dv }; | |
| } | |
| function readAccessorGLB(json, binBytes, dvAbs, binStartAbs, accIndex, asIndex) { | |
| const accessors = json.accessors || []; | |
| const bufferViews = json.bufferViews || []; | |
| const acc = accessors[accIndex]; | |
| if (!acc) throw new Error('Missing accessor'); | |
| const bv = bufferViews[acc.bufferView]; | |
| if (!bv && acc.bufferView != null) throw new Error('Missing bufferView'); | |
| const comps = ({ SCALAR:1, VEC2:2, VEC3:3, VEC4:4 })[acc.type] || 3; | |
| const typeSize = ({ 5120:1, 5121:1, 5122:2, 5123:2, 5125:4, 5126:4 })[acc.componentType]; | |
| if (!typeSize) throw new Error('Unsupported componentType'); | |
| const count = acc.count; | |
| const byteOffset = (bv ? (bv.byteOffset || 0) : 0) + (acc.byteOffset || 0); | |
| const stride = (bv && bv.byteStride) ? bv.byteStride : (comps * typeSize); | |
| const out = asIndex ? new Uint32Array(count) : new Float32Array(count * comps); | |
| const src = new DataView(binBytes); | |
| for (let i = 0; i < count; i++) { | |
| const base = byteOffset + i * stride; | |
| for (let c = 0; c < comps; c++) { | |
| const o = base + c * typeSize; | |
| let v; | |
| switch (acc.componentType) { | |
| case 5120: v = src.getInt8(o); break; | |
| case 5121: v = src.getUint8(o); break; | |
| case 5122: v = src.getInt16(o, true); break; | |
| case 5123: v = src.getUint16(o, true); break; | |
| case 5125: v = src.getUint32(o, true); break; | |
| case 5126: v = src.getFloat32(o, true); break; | |
| default: v = 0; | |
| } | |
| if (acc.normalized) { | |
| if (acc.componentType === 5120) v /= 127; | |
| else if (acc.componentType === 5121) v /= 255; | |
| else if (acc.componentType === 5122) v /= 32767; | |
| else if (acc.componentType === 5123) v /= 65535; | |
| } | |
| if (asIndex) out[i] = v >>> 0; | |
| else out[i * comps + c] = v; | |
| } | |
| } | |
| return { data: out, count: count, min: acc.min, max: acc.max }; | |
| } | |
| function expandPrimitiveToArrays(json, binBytes, prim) { | |
| const attrs = prim.attributes || {}; | |
| if (attrs.POSITION === undefined) return null; | |
| const posAcc = readAccessorGLB(json, binBytes, null, 0, attrs.POSITION, false); | |
| let normAcc = (attrs.NORMAL !== undefined) ? readAccessorGLB(json, binBytes, null, 0, attrs.NORMAL, false) : null; | |
| let uvAcc = (attrs.TEXCOORD_0 !== undefined) ? readAccessorGLB(json, binBytes, null, 0, attrs.TEXCOORD_0, false) : null; | |
| let tanAcc = (attrs.TANGENT !== undefined) ? readAccessorGLB(json, binBytes, null, 0, attrs.TANGENT, false) : null; | |
| let idx = (prim.indices !== undefined) ? readAccessorGLB(json, binBytes, null, 0, prim.indices, true).data : null; | |
| let posOut, normOut, uvOut, tanOut = null; | |
| if (idx) { | |
| const nTri = idx.length; | |
| posOut = new Float32Array(nTri * 3); | |
| normOut = new Float32Array(nTri * 3); | |
| uvOut = new Float32Array(nTri * 2); | |
| if (tanAcc) tanOut = new Float32Array(nTri * 4); | |
| for (let i = 0; i < nTri; i++) { | |
| const vi = idx[i]; | |
| posOut[i*3] = posAcc.data[vi*3]; | |
| posOut[i*3+1] = posAcc.data[vi*3+1]; | |
| posOut[i*3+2] = posAcc.data[vi*3+2]; | |
| if (normAcc) { | |
| normOut[i*3] = normAcc.data[vi*3]; | |
| normOut[i*3+1] = normAcc.data[vi*3+1]; | |
| normOut[i*3+2] = normAcc.data[vi*3+2]; | |
| } | |
| if (uvAcc) { | |
| uvOut[i*2] = uvAcc.data[vi*2] || 0; | |
| uvOut[i*2+1] = uvAcc.data[vi*2+1] || 0; | |
| } | |
| if (tanAcc && tanOut) { | |
| tanOut[i*4] = tanAcc.data[vi*4] || 0; | |
| tanOut[i*4+1] = tanAcc.data[vi*4+1] || 0; | |
| tanOut[i*4+2] = tanAcc.data[vi*4+2] || 0; | |
| tanOut[i*4+3] = tanAcc.data[vi*4+3] != null ? tanAcc.data[vi*4+3] : 1; | |
| } | |
| } | |
| } else { | |
| posOut = posAcc.data; | |
| normOut = normAcc ? normAcc.data : new Float32Array(posAcc.count * 3); | |
| uvOut = uvAcc ? uvAcc.data : new Float32Array(posAcc.count * 2); | |
| if (tanAcc) tanOut = tanAcc.data; | |
| } | |
| if (!normAcc) { | |
| for (let i = 0; i < posOut.length; i += 9) { | |
| const x0=posOut[i], y0=posOut[i+1], z0=posOut[i+2]; | |
| const x1=posOut[i+3], y1=posOut[i+4], z1=posOut[i+5]; | |
| const x2=posOut[i+6], y2=posOut[i+7], z2=posOut[i+8]; | |
| let nx = (y1-y0)*(z2-z0) - (z1-z0)*(y2-y0); | |
| let ny = (z1-z0)*(x2-x0) - (x1-x0)*(z2-z0); | |
| let nz = (x1-x0)*(y2-y0) - (y1-y0)*(x2-x0); | |
| const len = Math.hypot(nx,ny,nz) || 1; | |
| nx/=len; ny/=len; nz/=len; | |
| for (let k = 0; k < 3; k++) { | |
| normOut[i + k*3] = nx; | |
| normOut[i + k*3 + 1] = ny; | |
| normOut[i + k*3 + 2] = nz; | |
| } | |
| } | |
| } | |
| return { posOut, normOut, uvOut, tanOut, posAcc }; | |
| } | |
| /** Sync first-mesh parse (compat). Prefer loadGLBTextured for look. */ | |
| function parseGLB(arrayBuffer) { | |
| const { json, binBytes } = parseGLBChunks(arrayBuffer); | |
| const meshes = json.meshes || []; | |
| if (!meshes.length) throw new Error('No meshes'); | |
| const prims = meshes[0].primitives || []; | |
| let prim = null; | |
| for (let i = 0; i < prims.length; i++) { | |
| const m = prims[i].mode; | |
| if (m === undefined || m === 4) { prim = prims[i]; break; } | |
| } | |
| if (!prim) throw new Error('No TRIANGLES primitive'); | |
| const arrays = expandPrimitiveToArrays(json, binBytes, prim); | |
| if (!arrays) throw new Error('No POSITION'); | |
| glbLastBounds = meshBoundsFromPositions(arrays.posOut); | |
| if (arrays.posAcc.min && arrays.posAcc.max && arrays.posAcc.min.length >= 3) { | |
| const mn = arrays.posAcc.min, mx = arrays.posAcc.max; | |
| glbLastBounds = { min:mn.slice(0,3), max:mx.slice(0,3), size:[mx[0]-mn[0], mx[1]-mn[1], mx[2]-mn[2]] }; | |
| } | |
| return createMesh(arrays.posOut, arrays.normOut, arrays.uvOut); | |
| } | |
| function loadGLBTextured(arrayBuffer) { | |
| const { json, binBytes } = parseGLBChunks(arrayBuffer); | |
| const mats = json.materials || []; | |
| const materialColor = []; | |
| const materialTex = []; | |
| const materialNm = []; | |
| const materialNmScale = []; | |
| const texPromises = []; | |
| function queueEmbeddedTex(texIndex, assignFn) { | |
| if (texIndex == null || !json.textures || !json.images) return; | |
| const texInfo = json.textures[texIndex]; | |
| if (!texInfo) return; | |
| const imgIndex = texInfo.source; | |
| const image = json.images[imgIndex]; | |
| if (!image) return; | |
| let p; | |
| if (image.bufferView != null || (image.uri && image.uri.indexOf('data:') === 0)) { | |
| p = loadEmbeddedImageTexture(json, binBytes, imgIndex).catch(function () { return null; }); | |
| } else { | |
| p = Promise.resolve(null); | |
| } | |
| texPromises.push(p.then(assignFn)); | |
| } | |
| for (let mi = 0; mi < mats.length; mi++) { | |
| (function (mi) { | |
| const mat = mats[mi] || {}; | |
| const pbr = mat.pbrMetallicRoughness || {}; | |
| const bc = pbr.baseColorFactor || [1, 1, 1, 1]; | |
| materialColor[mi] = [bc[0], bc[1], bc[2]]; | |
| materialTex[mi] = null; | |
| materialNm[mi] = null; | |
| materialNmScale[mi] = 1.0; | |
| if (pbr.baseColorTexture) { | |
| queueEmbeddedTex(pbr.baseColorTexture.index, function (t) { materialTex[mi] = t; }); | |
| } | |
| if (mat.normalTexture) { | |
| const nt = mat.normalTexture; | |
| if (nt.scale != null) materialNmScale[mi] = nt.scale; | |
| queueEmbeddedTex(nt.index, function (t) { materialNm[mi] = t; }); | |
| } | |
| })(mi); | |
| } | |
| return Promise.all(texPromises).then(function () { | |
| const nodes = json.nodes || []; | |
| const meshes = json.meshes || []; | |
| const sceneIndex = json.scene != null ? json.scene : 0; | |
| const scene = (json.scenes && json.scenes[sceneIndex]) || { nodes: nodes.length ? [0] : [] }; | |
| const roots = scene.nodes || []; | |
| const parts = []; | |
| let aabbMin = [Infinity, Infinity, Infinity]; | |
| let aabbMax = [-Infinity, -Infinity, -Infinity]; | |
| function expandAABB(wx, wy, wz) { | |
| if (wx < aabbMin[0]) aabbMin[0] = wx; | |
| if (wy < aabbMin[1]) aabbMin[1] = wy; | |
| if (wz < aabbMin[2]) aabbMin[2] = wz; | |
| if (wx > aabbMax[0]) aabbMax[0] = wx; | |
| if (wy > aabbMax[1]) aabbMax[1] = wy; | |
| if (wz > aabbMax[2]) aabbMax[2] = wz; | |
| } | |
| function processMesh(meshIndex, world) { | |
| const mesh = meshes[meshIndex]; | |
| if (!mesh || !mesh.primitives) return; | |
| for (let p = 0; p < mesh.primitives.length; p++) { | |
| const prim = mesh.primitives[p]; | |
| const mode = prim.mode; | |
| if (mode !== undefined && mode !== 4) continue; | |
| const arrays = expandPrimitiveToArrays(json, binBytes, prim); | |
| if (!arrays) continue; | |
| for (let i = 0; i < arrays.posOut.length; i += 3) { | |
| const wp = transformPoint(world, arrays.posOut[i], arrays.posOut[i+1], arrays.posOut[i+2]); | |
| expandAABB(wp[0], wp[1], wp[2]); | |
| } | |
| const matIndex = prim.material != null ? prim.material : -1; | |
| const color = matIndex >= 0 && materialColor[matIndex] ? materialColor[matIndex].slice() : [0.85, 0.85, 0.85]; | |
| const tex = matIndex >= 0 ? materialTex[matIndex] : null; | |
| const nm = matIndex >= 0 ? materialNm[matIndex] : null; | |
| const nmScale = matIndex >= 0 && materialNmScale[matIndex] != null ? materialNmScale[matIndex] : 1.0; | |
| let gm; // [lookB-fix] was `let mesh`, which shadowed the outer mesh -> TDZ ReferenceError on every GLB | |
| if (nm || arrays.tanOut) { | |
| const tans = arrays.tanOut || computeTangentsFromUV(arrays.posOut, arrays.normOut, arrays.uvOut); | |
| gm = createMeshWithTangents(arrays.posOut, arrays.normOut, arrays.uvOut, tans); | |
| } else { | |
| gm = createMesh(arrays.posOut, arrays.normOut, arrays.uvOut); | |
| } | |
| parts.push({ | |
| mesh: gm, | |
| color: color, | |
| tex: tex, | |
| useTex: !!tex, | |
| nm: nm, | |
| useNm: !!nm, | |
| nmScale: nmScale, | |
| world: new Float32Array(world) | |
| }); | |
| } | |
| } | |
| function walk(nodeIndex, parentWorld) { | |
| const node = nodes[nodeIndex]; | |
| if (!node) return; | |
| const local = nodeLocalMatrix(node); | |
| const world = multiply(parentWorld, local); | |
| if (node.mesh != null) processMesh(node.mesh, world); | |
| const children = node.children || []; | |
| for (let c = 0; c < children.length; c++) walk(children[c], world); | |
| } | |
| const I = identity(); | |
| if (!roots.length && nodes.length) { | |
| for (let ri = 0; ri < nodes.length; ri++) walk(ri, I); | |
| } else { | |
| for (let r = 0; r < roots.length; r++) walk(roots[r], I); | |
| } | |
| if (!isFinite(aabbMin[0])) { aabbMin = [-1, 0, -1]; aabbMax = [1, 1, 1]; } | |
| const cx = (aabbMin[0] + aabbMax[0]) * 0.5; | |
| const cz = (aabbMin[2] + aabbMax[2]) * 0.5; | |
| const sx = aabbMax[0] - aabbMin[0], sy = aabbMax[1] - aabbMin[1], sz = aabbMax[2] - aabbMin[2]; | |
| const extent = Math.max(sx, sy, sz) || 1; | |
| const fitScale = 1.6 / extent; | |
| let fit = identity(); | |
| const sm = identity(); sm[0]=fitScale; sm[5]=fitScale; sm[10]=fitScale; | |
| const tm = identity(); tm[12]=-cx; tm[13]=-aabbMin[1]; tm[14]=-cz; | |
| fit = multiply(sm, tm); | |
| glbLastBounds = { | |
| min: aabbMin.slice(), max: aabbMax.slice(), | |
| size: [sx, sy, sz] | |
| }; | |
| for (let d = 0; d < parts.length; d++) { | |
| parts[d].local = multiply(fit, parts[d].world); | |
| } | |
| return { parts: parts, bounds: glbLastBounds, fitScale: fitScale }; | |
| }); | |
| } | |
| const triPos = new Float32Array([0,1.2,0, -1.0,-0.5,0, 1.0,-0.5,0]); | |
| const triNorm = new Float32Array([0,0,1, 0,0,1, 0,0,1]); | |
| const triangle = createMesh(triPos, triNorm); | |
| const size = 10; | |
| const groundPos = new Float32Array([ | |
| -size,0,-size, size,0,-size, size,0,size, | |
| -size,0,-size, size,0,size, -size,0,size | |
| ]); | |
| const groundNorm = new Float32Array([0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0]); | |
| const ground = createMesh(groundPos, groundNorm); | |
| const boxSize = 1.0; | |
| const h = boxSize*0.5; | |
| const bPos = new Float32Array([ | |
| -h,-h, h, h,-h, h, h, h, h, | |
| -h,-h, h, h, h, h, -h, h, h, | |
| -h,-h,-h, -h, h,-h, h, h,-h, | |
| -h,-h,-h, h, h,-h, h,-h,-h, | |
| -h,-h,-h, -h,-h, h, -h, h, h, | |
| -h,-h,-h, -h, h, h, -h, h,-h, | |
| h,-h,-h, h, h,-h, h, h, h, | |
| h,-h,-h, h, h, h, h,-h, h, | |
| -h, h,-h, -h, h, h, h, h, h, | |
| -h, h,-h, h, h, h, h, h,-h, | |
| -h,-h,-h, h,-h,-h, h,-h, h, | |
| -h,-h,-h, h,-h, h, -h,-h, h | |
| ]); | |
| const bNorm = new Float32Array([ | |
| 0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1, | |
| 0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,-1, | |
| -1,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0, | |
| 1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0, | |
| 0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0, | |
| 0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0 | |
| ]); | |
| const crateMesh = createMesh(bPos, bNorm); | |
| const shadowQuadPos = new Float32Array([-1,0,-1, 1,0,-1, 1,0,1, -1,0,-1, 1,0,1, -1,0,1]); | |
| const shadowQuadNorm = new Float32Array([0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0]); | |
| const shadowQuad = createMesh(shadowQuadPos, shadowQuadNorm); | |
| const gridLines = []; | |
| const step = 1; | |
| for (let i=-size;i<=size;i++){ | |
| gridLines.push(-size,0,i, size,0,i); | |
| gridLines.push(i,0,-size, i,0,size); | |
| } | |
| const gridVBO = gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, gridVBO); | |
| gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(gridLines), gl.STATIC_DRAW); | |
| function perspective(fov, aspect, near, far) { | |
| const f = 1.0 / Math.tan(fov * 0.5); | |
| const out = new Float32Array(16); | |
| out[0] = f / aspect; out[1]=0; out[2]=0; out[3]=0; | |
| out[4]=0; out[5]=f; out[6]=0; out[7]=0; | |
| out[8]=0; out[9]=0; out[10]=(far+near)/(near-far); out[11]=-1; | |
| out[12]=0; out[13]=0; out[14]=(2*far*near)/(near-far); out[15]=0; | |
| return out; | |
| } | |
| function ortho(l,r,b,t,n,f){ | |
| const out = new Float32Array(16); | |
| out[0]=2/(r-l); out[1]=0; out[2]=0; out[3]=0; | |
| out[4]=0; out[5]=2/(t-b); out[6]=0; out[7]=0; | |
| out[8]=0; out[9]=0; out[10]=-2/(f-n); out[11]=0; | |
| out[12]=-(r+l)/(r-l); out[13]=-(t+b)/(t-b); out[14]=-(f+n)/(f-n); out[15]=1; | |
| return out; | |
| } | |
| function lookAt(eye, center, up) { | |
| const zx = eye[0]-center[0], zy = eye[1]-center[1], zz = eye[2]-center[2]; | |
| let zl = Math.hypot(zx,zy,zz)||1; const fx = zx/zl, fy = zy/zl, fz = zz/zl; | |
| const ux = up[0], uy = up[1], uz = up[2]; | |
| let sx = fy*uz - fz*uy, sy = fz*ux - fx*uz, sz = fx*uy - fy*ux; | |
| let sl = Math.hypot(sx,sy,sz)||1; sx/=sl; sy/=sl; sz/=sl; | |
| const cx = sy*fz - sz*fy, cy = sz*fx - sx*fz, cz = sx*fy - sy*fx; | |
| const out = new Float32Array(16); | |
| out[0]=sx; out[1]=cx; out[2]=fx; out[3]=0; | |
| out[4]=sy; out[5]=cy; out[6]=fy; out[7]=0; | |
| out[8]=sz; out[9]=cz; out[10]=fz; out[11]=0; | |
| out[12]=-(sx*eye[0]+sy*eye[1]+sz*eye[2]); | |
| out[13]=-(cx*eye[0]+cy*eye[1]+cz*eye[2]); | |
| out[14]=-(fx*eye[0]+fy*eye[1]+fz*eye[2]); | |
| out[15]=1; | |
| return out; | |
| } | |
| function multiply(a,b){ | |
| const out = new Float32Array(16); | |
| for(let i=0;i<4;i++) for(let j=0;j<4;j++){ | |
| out[i*4+j]= a[i*4+0]*b[0*4+j]+a[i*4+1]*b[1*4+j]+a[i*4+2]*b[2*4+j]+a[i*4+3]*b[3*4+j]; | |
| } | |
| return out; | |
| } | |
| function rotateX(m, a){ | |
| const c = Math.cos(a), s = Math.sin(a); | |
| const out = m.slice(); | |
| const r1c0=out[4], r2c0=out[8], r1c1=out[5], r2c1=out[9], r1c2=out[6], r2c2=out[10], r1c3=out[7], r2c3=out[11]; | |
| out[4] = c*r1c0 - s*r2c0; | |
| out[8] = s*r1c0 + c*r2c0; | |
| out[5] = c*r1c1 - s*r2c1; | |
| out[9] = s*r1c1 + c*r2c1; | |
| out[6] = c*r1c2 - s*r2c2; | |
| out[10]= s*r1c2 + c*r2c2; | |
| out[7] = c*r1c3 - s*r2c3; | |
| out[11]= s*r1c3 + c*r2c3; | |
| return out; | |
| } | |
| function rotateY(m, a){ | |
| const c=Math.cos(a), s=Math.sin(a); | |
| const r = new Float32Array(16); | |
| r[0]=c; r[1]=0; r[2]=-s; r[3]=0; | |
| r[4]=0; r[5]=1; r[6]=0; r[7]=0; | |
| r[8]=s; r[9]=0; r[10]=c; r[11]=0; | |
| r[12]=0; r[13]=0; r[14]=0; r[15]=1; | |
| return multiply(r,m); | |
| } | |
| function translate(m, x,y,z){ | |
| const out = new Float32Array(m); | |
| out[12] = m[0]*x + m[4]*y + m[8]*z + m[12]; | |
| out[13] = m[1]*x + m[5]*y + m[10]*z + m[14]; | |
| out[14] = m[2]*x + m[6]*y + m[10]*z + m[14]; | |
| return out; | |
| } | |
| function scale(m, s){ | |
| const out = new Float32Array(m); | |
| out[0]*=s; out[1]*=s; out[2]*=s; | |
| out[4]*=s; out[5]*=s; out[6]*=s; | |
| out[8]*=s; out[9]*=s; out[10]*=s; | |
| return out; | |
| } | |
| function identity(){ return new Float32Array([1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]); } | |
| const cam = { | |
| pos: [0,1.6,5], | |
| yaw: 0, | |
| pitch: 0, | |
| speed: 4.0, | |
| sens: 0.002 | |
| }; | |
| const orbit = { | |
| enabled: false, | |
| yaw: 0.6, | |
| pitch: 0.35, | |
| radius: 9.0, | |
| target: [0,0.8, -1.5], | |
| yawTarget: 0.6, | |
| pitchTarget: 0.35 | |
| }; | |
| const keys = {}; | |
| let nightMode = false; | |
| let score = 0; | |
| let shootCooldown = 0; | |
| let hitFlashTimer = 0; | |
| let hitStyleGl = [1.0, 0.92, 0.25]; | |
| let hitStyleDur = 0.28; | |
| let playerHP = 5; | |
| let hurtCooldown = 0; | |
| let hurtFlash = 0; | |
| let charShapePack = 0; | |
| const CHAR_SHAPES = [ | |
| { id:'BOX', dogXZ:1.0, dogY:0.6, dogZ:1.2, dogRadExtra:0.35, hostScale:1.2, patrolScale:1.1 }, | |
| { id:'TALL', dogXZ:0.75, dogY:1.15, dogZ:0.9, dogRadExtra:0.5, hostScale:1.55, patrolScale:1.4 } | |
| ]; | |
| function applyCharShapePack(){ | |
| const sh = CHAR_SHAPES[charShapePack]; | |
| for (const d of dogs){ | |
| d.shapeXZ = sh.dogXZ; d.shapeY = sh.dogY; d.shapeZ = sh.dogZ; d.radExtra = sh.dogRadExtra; | |
| } | |
| hostile.scale = sh.hostScale; | |
| patrolHostile.scale = sh.patrolScale; | |
| flashHint('CHARS ' + sh.id + ' (distance test)'); | |
| } | |
| const MAG_CAP = 12; | |
| let ammoMag = 8; | |
| let ammoReserve = 16; | |
| let wonOnce = false; | |
| let reloading = false; | |
| let reloadTimer = 0; | |
| let muzzleFlash = 0; | |
| let crouching = false; | |
| const standEye = 1.6; | |
| const crouchEye = 1.05; | |
| let velY = 0; | |
| let airY = 0; | |
| let onGround = true; | |
| let onLadder = false; | |
| let coyoteTimer = 0; | |
| let jumpBufferTimer = 0; | |
| let wasOnGround = true; | |
| const GRAVITY = 24; | |
| const JUMP_VEL = 8.2; | |
| const CLIMB_SPEED = 2.8; | |
| const COYOTE_TIME = 0.10; | |
| const JUMP_BUFFER = 0.12; | |
| const STEP_UP = 0.55; | |
| const MANTLE_MAX = 1.15; | |
| const PLAYER_R = 0.35; | |
| // ===== BEGIN GAME DATA: Breaker Bay level data / tuning (MIT like the rest; still inline here until the engine/game split in PLAN.md moves it to game/) ===== | |
| // Phase 28 parkour route (right of spawn, clear of bollard/pier exhibits) | |
| const parkourBlocks = [ | |
| { pos:[3.6, 0.25, 3.4], scale:[1.2, 0.50, 1.2], color:[0.72,0.42,0.22] }, | |
| { pos:[4.9, 0.80, 2.7], scale:[1.15, 0.70, 1.15], color:[0.75,0.48,0.25] }, | |
| { pos:[6.1, 1.40, 1.9], scale:[1.2, 0.70, 1.2], color:[0.70,0.40,0.20] }, | |
| { pos:[7.2, 2.10, 1.0], scale:[1.25, 0.70, 1.25], color:[0.35,0.55,0.78] } | |
| ]; | |
| const ladder = { pos:[7.2, 1.55, -0.15], scale:[0.34, 3.1, 0.34], color:[0.55,0.38,0.22], spec:[0.25,0.18,0.12], shin:18 }; | |
| const ladderDeck = { pos:[7.2, 3.20, -0.9], scale:[2.0, 0.22, 1.8], color:[0.30,0.70,0.45], spec:[0.25,0.40,0.30], shin:24 }; | |
| // Phase 29 — hostile death timeline: fall/settle then boom/despawn | |
| const HOSTILE_DEATH_FALL = 0.42; | |
| const HOSTILE_DEATH_BOOM = 0.18; | |
| const HOSTILE_DEATH_TOTAL = HOSTILE_DEATH_FALL + HOSTILE_DEATH_BOOM; | |
| const hostile = { | |
| mesh: crateMesh, | |
| pos: [-7.2, 0.6, -5.0], | |
| scale: 1.2, | |
| hp: 5, | |
| alive: true, | |
| speed: 1.35, | |
| aggroRange: 8.0, | |
| loseRange: 18.0, | |
| alerted: false, | |
| hitFlash: 0, | |
| dying: 0, | |
| deathBoomPending: false, | |
| tipSign: 1, | |
| baseY: 0.0 | |
| }; | |
| const patrolHostile = { | |
| mesh: crateMesh, | |
| pos: [4.0, 0.6, 2.0], | |
| scale: 1.1, | |
| hp: 5, | |
| alive: true, | |
| speed: 1.8, | |
| hitFlash: 0, | |
| waypointA: [4.0, 0.6, 2.0], | |
| waypointB: [-4.0, 0.6, 2.0], | |
| targetIsA: false, | |
| hurtCooldown: 0, | |
| dying: 0, | |
| deathBoomPending: false, | |
| tipSign: -1, | |
| baseY: 0.0 | |
| }; | |
| // Phase 25 lean: fewer dogs / ammo clutter; keep shootables | |
| const dogs = [ | |
| { mesh: crateMesh, pos: [0.5, 0.25, -1.2], scale: 0.9, color:[0.55,0.45,0.35], spec:[0.3,0.25,0.2], shin:24, wanderTimer:0, speed:0.95, target:[0,0,0], alive:true, hp:2, hitFlash:0 } | |
| ]; | |
| const ammoPickups = [ | |
| { mesh: crateMesh, pos: [-2.5, 0.35, -3.5], scale: 0.75, color: [0.42,0.28,0.16], spec:[0.25,0.2,0.15], shin:28, alive:true, amount:10, name:'ammoPick0', accent:[0.25,0.95,0.95] }, | |
| { mesh: crateMesh, pos: [5.2, 0.35, 0.8], scale: 0.75, color: [0.42,0.28,0.16], spec:[0.25,0.2,0.15], shin:28, alive:true, amount:12, name:'ammoPick1', accent:[0.98,0.82,0.2] }, | |
| { mesh: crateMesh, pos: [2.4, 0.35, -6.4], scale: 0.75, color: [0.42,0.28,0.16], spec:[0.25,0.2,0.15], shin:28, alive:true, amount:10, name:'ammoPick2', accent:[0.35,0.95,0.45] } | |
| ]; | |
| function drawAmmoBox(p){ | |
| const ls = p.scale; | |
| gl.uniform1f(uUseTex, 0.0); | |
| let m = identity(); | |
| m[0]=ls; m[5]=ls*0.55; m[10]=ls; | |
| m[12]=p.pos[0]; m[13]=p.pos[1]; m[14]=p.pos[2]; | |
| gl.uniformMatrix4fv(uModel, false, m); | |
| drawMesh(p.mesh, m, p.color, p.spec, p.shin); | |
| let lid = identity(); | |
| lid[0]=ls*0.95; lid[5]=ls*0.75; lid[10]=ls*0.08; | |
| lid[12]=p.pos[0]; lid[13]=p.pos[1] + ls*0.35; lid[14]=p.pos[2] - ls*0.55; | |
| gl.uniformMatrix4fv(uModel, false, lid); | |
| drawMesh(p.mesh, lid, curLook.ammo.lid, [0.2,0.15,0.1], 22); | |
| const acc = p.accent || [0.9,0.85,0.3]; | |
| for (let i = 0; i < 3; i++){ | |
| let a = identity(); | |
| a[0]=ls*0.16; a[5]=ls*0.28; a[10]=ls*0.48; | |
| a[12]=p.pos[0] + (i-1)*ls*0.26; | |
| a[13]=p.pos[1] + ls*0.28; | |
| a[14]=p.pos[2] + ls*0.02; | |
| gl.uniformMatrix4fv(uModel, false, a); | |
| drawMesh(p.mesh, a, acc, [1.0,1.0,0.85], 90); | |
| } | |
| } | |
| const door = { | |
| mesh: crateMesh, | |
| pos: [-4.5, 1.1, -2.0], | |
| scale: [0.25, 2.2, 1.6], | |
| color: [0.6,0.6,0.65], | |
| spec: [0.2,0.2,0.25], | |
| shin: 20, | |
| open: false, | |
| name: 'door' | |
| }; | |
| const lamps = [ | |
| { name:'lamp0', pos:[0.4,0.0,-2.2], postScale:[0.12,1.8,0.12], bulbScale:[0.22,0.22,0.22], warm:true, on:true, broken:false }, | |
| { name:'lamp1', pos:[-2.0,0.0,-1.0], postScale:[0.12,1.6,0.12], bulbScale:[0.22,0.22,0.22], warm:true, on:true, broken:false }, | |
| { name:'lamp2', pos:[3.2,0.0,-2.6], postScale:[0.14,1.9,0.14], bulbScale:[0.28,0.28,0.28], warm:true, on:true, broken:false } | |
| ]; | |
| // ===== END GAME DATA ===== | |
| function lampBulbTip(l){ | |
| return [l.pos[0], l.pos[1] + l.postScale[1] + l.bulbScale[1]*0.5, l.pos[2]]; | |
| } | |
| function lampLit(l){ | |
| return nightMode && l.on && !l.broken; | |
| } | |
| function nearestLitLamps(n){ | |
| const lit = lamps.filter(lampLit); | |
| if (lit.length <= n) return lit.slice(); | |
| lit.sort((a,b)=>{ | |
| const ta = lampBulbTip(a), tb = lampBulbTip(b); | |
| const da = Math.hypot(ta[0]-cam.pos[0], ta[2]-cam.pos[2]); | |
| const db = Math.hypot(tb[0]-cam.pos[0], tb[2]-cam.pos[2]); | |
| return da - db; | |
| }); | |
| if (n >= 2 && lit.length >= 2) { | |
| return [lit[0], lit[lit.length - 1]]; | |
| } | |
| return lit.slice(0, n); | |
| } | |
| let audioCtx = null; | |
| function ensureAudio(){ | |
| if (audioCtx) return; | |
| try { | |
| audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| } catch(e){} | |
| } | |
| function beep(freq, dur, vol){ | |
| if (!cfg.sound) return; // [e29] sound toggle | |
| try { | |
| ensureAudio(); | |
| if (!audioCtx) return; | |
| if (audioCtx.state === 'suspended') audioCtx.resume(); | |
| const o = audioCtx.createOscillator(); | |
| const g = audioCtx.createGain(); | |
| o.type = 'square'; | |
| o.frequency.value = freq; | |
| g.gain.value = vol; | |
| o.connect(g); g.connect(audioCtx.destination); | |
| o.start(); | |
| g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + dur); | |
| o.stop(audioCtx.currentTime + dur); | |
| } catch(e){} | |
| } | |
| function sfxShoot(){ beep(700, 0.06, 0.05); } | |
| function sfxReloadStart(){ beep(300, 0.08, 0.04); } | |
| function sfxReloadEnd(){ beep(600, 0.12, 0.05); } | |
| function sfxHit(){ beep(200, 0.07, 0.05); } | |
| function sfxHurt(){ beep(140, 0.12, 0.07); } | |
| function sfxPickup(){ beep(880, 0.07, 0.05); beep(1200, 0.08, 0.04); } | |
| function sfxDoor(){ beep(180, 0.1, 0.05); } | |
| function sfxBump(){ beep(110, 0.05, 0.06); } | |
| function sfxWin(){ beep(520, 0.1, 0.05); beep(780, 0.15, 0.05); } | |
| let _bumpSfxAt = 0; | |
| function sfxBumpThrottled(minGap){ | |
| const t = (typeof performance !== 'undefined' ? performance.now() : Date.now()); | |
| if (t - _bumpSfxAt < (minGap || 180)) return; | |
| _bumpSfxAt = t; | |
| sfxBump(); | |
| } | |
| function enterOrbit(){ | |
| if (orbit.enabled) return; | |
| orbit._saved = [cam.pos[0], cam.pos[1], cam.pos[2], cam.yaw, cam.pitch]; | |
| orbit.enabled = true; | |
| document.exitPointerLock?.(); | |
| } | |
| function exitOrbit(){ | |
| if (!orbit.enabled) return; | |
| orbit.enabled = false; | |
| if (orbit._saved) { | |
| cam.pos[0] = orbit._saved[0]; | |
| cam.pos[2] = orbit._saved[2]; | |
| cam.yaw = orbit._saved[3]; | |
| cam.pitch = orbit._saved[4]; | |
| } | |
| cam.pos[1] = crouching ? crouchEye : standEye; | |
| } | |
| let demoDrive = null; | |
| function stopDemoDrive(){ | |
| if (demoDrive) { | |
| clearInterval(demoDrive); | |
| demoDrive = null; | |
| } | |
| keys['KeyW'] = keys['KeyS'] = keys['KeyA'] = keys['KeyD'] = false; | |
| } | |
| function toggleNight(){ | |
| nightMode = !nightMode; | |
| cfg.night = nightMode; // [e29] | |
| flashHint(nightMode ? ('NIGHT — ' + lamps.filter(l=>l.on && !l.broken).length + ' LAMPS') : 'DAY — LAMPS OFF'); | |
| } | |
| window.addEventListener('keydown', e=>{ | |
| if (uiBlocked) return; // [e29] overlay open | |
| if (['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) { | |
| e.preventDefault(); | |
| stopDemoDrive(); | |
| } | |
| keys[e.code]=true; | |
| if (e.code === 'Space' && !crouching) { | |
| jumpBufferTimer = JUMP_BUFFER; | |
| } | |
| if (e.code === 'ControlLeft' || e.code === 'ControlRight') { | |
| crouching = true; | |
| } | |
| if (e.code === 'KeyO') { | |
| if (orbit.enabled) exitOrbit(); else enterOrbit(); | |
| } | |
| if (e.code === 'Escape') { | |
| exitOrbit(); | |
| document.exitPointerLock?.(); | |
| } | |
| if (e.code === 'KeyN' || e.code === 'KeyG') { | |
| toggleNight(); | |
| } | |
| if (e.code === 'KeyL') { | |
| e.preventDefault(); | |
| if (e.shiftKey) openGlbPicker(); else cycleLook(); // [e29] L = look, Shift+L = GLB pick | |
| } | |
| if (e.code === 'KeyK') { | |
| e.preventDefault(); | |
| toggleLookProp(); | |
| } | |
| if (e.code === 'KeyC') { | |
| showCredit(2000); | |
| } | |
| if (e.code === 'KeyP') { | |
| coachDump(); | |
| } | |
| if (e.code === 'KeyT') { | |
| charShapePack = (charShapePack + 1) % CHAR_SHAPES.length; | |
| applyCharShapePack(); | |
| } | |
| if (e.code === 'KeyR') { | |
| if (!reloading && ammoMag < MAG_CAP && ammoReserve > 0) { | |
| reloading = true; | |
| reloadTimer = 1.5; | |
| flashHint('RELOADING'); | |
| sfxReloadStart(); | |
| } | |
| } | |
| if (e.code === 'KeyE') { | |
| const dx = cam.pos[0] - door.pos[0]; | |
| const dz = cam.pos[2] - door.pos[2]; | |
| if (Math.hypot(dx, dz) < 2.2) { | |
| door.open = !door.open; | |
| sfxDoor(); flashHint(door.open ? 'DOOR OPEN' : 'DOOR CLOSED'); | |
| } | |
| } | |
| }); | |
| window.addEventListener('keyup', e=>{ | |
| keys[e.code]=false; | |
| if (e.code === 'ControlLeft' || e.code === 'ControlRight') { | |
| crouching = false; | |
| } | |
| }); | |
| let mouseDown=false; | |
| let pendingShoot = false; | |
| document.addEventListener('pointerlockchange', ()=>{ | |
| if (document.pointerLockElement === canvas && pendingShoot) { | |
| pendingShoot = false; | |
| tryShoot(); | |
| } | |
| }); | |
| canvas.addEventListener('mousedown', (e)=>{ | |
| if (uiBlocked) return; // [e29] | |
| stopDemoDrive(); | |
| mouseDown=true; | |
| if (e.button === 0) { | |
| if (document.pointerLockElement === canvas) { | |
| tryShoot(); | |
| } else { | |
| pendingShoot = true; | |
| canvas.requestPointerLock?.(); | |
| } | |
| } | |
| }); | |
| window.addEventListener('mouseup', ()=>mouseDown=false); | |
| canvas.addEventListener('mousemove', e=>{ | |
| if (orbit.enabled) { | |
| if(!mouseDown) return; | |
| orbit.yawTarget += e.movementX * cam.sens; | |
| orbit.pitchTarget += e.movementY * cam.sens; | |
| orbit.pitchTarget = Math.max(-1.4, Math.min(1.4, orbit.pitchTarget)); | |
| } else { | |
| if(!mouseDown && document.pointerLockElement !== canvas) return; | |
| cam.yaw += e.movementX * cam.sens; | |
| cam.pitch += e.movementY * cam.sens; | |
| cam.pitch = Math.max(-1.5, Math.min(1.5, cam.pitch)); | |
| } | |
| }); | |
| function flatForward(){ | |
| return [Math.sin(cam.yaw), 0, -Math.cos(cam.yaw)]; | |
| } | |
| function flatRight(){ | |
| return [Math.cos(cam.yaw), 0, Math.sin(cam.yaw)]; | |
| } | |
| function updateCamera(dt){ | |
| if (orbit.enabled) { | |
| orbit.yaw += (orbit.yawTarget - orbit.yaw) * 0.12; | |
| orbit.pitch += (orbit.pitchTarget - orbit.pitch) * 0.12; | |
| const cp = Math.cos(orbit.pitch); | |
| const sp = Math.sin(orbit.pitch); | |
| const cy = Math.cos(orbit.yaw); | |
| const sy = Math.sin(orbit.yaw); | |
| cam.pos[0] = orbit.target[0] + orbit.radius * cp * sy; | |
| cam.pos[1] = orbit.target[1] + orbit.radius * sp; | |
| cam.pos[2] = orbit.target[2] + orbit.radius * cp * cy; | |
| return; | |
| } | |
| const fwd = flatForward(); | |
| const right = flatRight(); | |
| let move = 0; | |
| let strafe = 0; | |
| if(keys['KeyW']||keys['ArrowUp']) move += 1; | |
| if(keys['KeyS']||keys['ArrowDown']) move -= 1; | |
| if(keys['KeyA']||keys['ArrowLeft']) strafe += 1; | |
| if(keys['KeyD']||keys['ArrowRight']) strafe -= 1; | |
| // diagonal: both keys at once (W+A = forward-left), normalize so speed stays even | |
| const mlen = Math.hypot(move, strafe); | |
| if (mlen > 1e-6) { move /= mlen; strafe /= mlen; } | |
| const eye = crouching ? crouchEye : standEye; | |
| function nodeTopY(node) { | |
| if (!node || !node.pos) return 0; | |
| const s = node.scale; | |
| const sy = Array.isArray(s) ? s[1] : (typeof s === 'number' ? s : 1); | |
| if (node.pivot === 'base' && node.aabbH != null) return node.pos[1] + node.aabbH; | |
| // center-pivot crates / boxes / parkour: pos is center | |
| if (Array.isArray(s) || (typeof s === 'number' && node.pivot !== 'base')) { | |
| return node.pos[1] + sy * 0.5; | |
| } | |
| return node.pos[1] + sy * 0.5; | |
| } | |
| function collectWalkTops() { | |
| const tops = []; | |
| for (let i = 0; i < parkourBlocks.length; i++) { | |
| const pl = parkourBlocks[i]; | |
| tops.push({ | |
| x: pl.pos[0], z: pl.pos[2], | |
| hx: pl.scale[0] * 0.5 + 0.06, hz: pl.scale[2] * 0.5 + 0.06, | |
| top: pl.pos[1] + pl.scale[1] * 0.5 | |
| }); | |
| } | |
| // ladder deck | |
| if (cfg.parkour) tops.push({ | |
| x: ladderDeck.pos[0], z: ladderDeck.pos[2], | |
| hx: ladderDeck.scale[0] * 0.5 + 0.06, hz: ladderDeck.scale[2] * 0.5 + 0.06, | |
| top: ladderDeck.pos[1] + ladderDeck.scale[1] * 0.5 | |
| }); | |
| // pier + any walkable scene nodes + solid crates (land on tops) | |
| for (let i = 0; i < sceneNodes.length; i++) { | |
| const n = sceneNodes[i]; | |
| if (!n.pos) continue; | |
| if (n.name === 'ground' || n.name === 'marker') continue; | |
| if (n.climb) continue; | |
| if (n.hp !== undefined && n.hp <= 0 && !(n.dying > 0)) continue; | |
| const s = n.scale; | |
| const sx = Array.isArray(s) ? s[0] : s; | |
| const sz = Array.isArray(s) ? s[2] : s; | |
| const top = nodeTopY(n); | |
| if (!(n.walkable || n.name === 'pier' || n.hp !== undefined || n.solidR)) continue; | |
| tops.push({ | |
| x: n.pos[0], z: n.pos[2], | |
| hx: (Array.isArray(s) ? sx : sx) * 0.5 + 0.08, | |
| hz: (Array.isArray(s) ? sz : sz) * 0.5 + 0.08, | |
| top: top | |
| }); | |
| } | |
| // bollard look prop (proxy cylinder as box) | |
| if (typeof lookProp !== 'undefined' && lookProp && lookProp.visible) { | |
| tops.push({ | |
| x: lookProp.pos[0], z: lookProp.pos[2], | |
| hx: 0.38, hz: 0.38, | |
| top: lookProp.pos[1] + 1.15 | |
| }); | |
| } | |
| return tops; | |
| } | |
| function platformFloor(px, pz, fromY) { | |
| if (levelOn()) return levelFloor(px, pz, fromY); // [e30] loaded scene is the floor | |
| let best = 0.0; | |
| const tops = collectWalkTops(); | |
| for (let i = 0; i < tops.length; i++) { | |
| const pl = tops[i]; | |
| if (Math.abs(px - pl.x) <= pl.hx && Math.abs(pz - pl.z) <= pl.hz) { | |
| // land only when above / near this top (not under a ceiling) | |
| if (fromY + 0.55 >= pl.top && pl.top > best) best = pl.top; | |
| } | |
| } | |
| return best; | |
| } | |
| function tryJump() { | |
| if (crouching || !cfg.jump) return false; // [e29] jump toggle | |
| if (!(onGround || coyoteTimer > 0 || onLadder)) return false; | |
| if (onLadder) { | |
| onLadder = false; | |
| velY = JUMP_VEL * 0.85; | |
| onGround = false; | |
| coyoteTimer = 0; | |
| jumpBufferTimer = 0; | |
| try { beep(420, 0.05, 0.04); } catch (_) {} | |
| flashHint('JUMP OFF'); | |
| return true; | |
| } | |
| // no infinite air-jump: require ground or coyote | |
| velY = JUMP_VEL; | |
| onGround = false; | |
| coyoteTimer = 0; | |
| jumpBufferTimer = 0; | |
| wasOnGround = false; | |
| try { beep(420, 0.05, 0.04); } catch (_) {} | |
| flashHint('JUMP'); | |
| return true; | |
| } | |
| // --- ladder mount / climb --- | |
| const lx = ladder.pos[0], lz = ladder.pos[2]; | |
| const deckTop = ladderDeck.pos[1] + ladderDeck.scale[1] * 0.5; | |
| const ladderTop = deckTop; | |
| const nearLadderXZ = cfg.parkour && Math.hypot(cam.pos[0] - lx, cam.pos[2] - lz) < 0.9; | |
| const climbPressed = !!(keys['KeyW']||keys['KeyS']||keys['ArrowUp']||keys['ArrowDown']); | |
| const onDeckXZ = ( | |
| Math.abs(cam.pos[0] - ladderDeck.pos[0]) < ladderDeck.scale[0] * 0.5 && | |
| Math.abs(cam.pos[2] - ladderDeck.pos[2]) < ladderDeck.scale[2] * 0.5 | |
| ); | |
| if (!onLadder && nearLadderXZ && !onDeckXZ && airY < deckTop - 0.25 && climbPressed) { | |
| onLadder = true; | |
| velY = 0; | |
| onGround = false; | |
| flashHint('LADDER'); | |
| } | |
| if (onLadder) { | |
| velY = 0; | |
| let climb = 0; | |
| if (keys['KeyW']||keys['ArrowUp']) climb += 1; | |
| if (keys['KeyS']||keys['ArrowDown']) climb -= 1; | |
| airY += climb * CLIMB_SPEED * dt; | |
| cam.pos[0] += (lx - cam.pos[0]) * Math.min(1, 3.5 * dt); | |
| cam.pos[2] += (lz - cam.pos[2]) * Math.min(1, 3.5 * dt); | |
| // A/D step off | |
| if (keys['KeyA']||keys['ArrowLeft']||keys['KeyD']||keys['ArrowRight']) { | |
| cam.pos[0] += right[0] * strafe * cam.speed * 0.55 * dt; | |
| cam.pos[2] += right[2] * strafe * cam.speed * 0.55 * dt; | |
| if (Math.hypot(cam.pos[0] - lx, cam.pos[2] - lz) > 1.1) onLadder = false; | |
| } | |
| if (airY <= 0) { airY = 0; onLadder = false; onGround = true; velY = 0; } | |
| if (airY >= ladderTop - 0.05) { | |
| airY = ladderTop; | |
| onLadder = false; | |
| velY = 0; | |
| onGround = true; | |
| flashHint('DECK'); | |
| } | |
| if (jumpBufferTimer > 0) tryJump(); | |
| cam.pos[1] = eye + airY; | |
| return; | |
| } | |
| // intended horizontal delta | |
| const dx = (fwd[0]*move + right[0]*strafe) * cam.speed * dt; | |
| const dz = (fwd[2]*move + right[2]*strafe) * cam.speed * dt; | |
| // step-up / mantle probe: if forward is blocked at feet but clear at raised height, lift | |
| function blockedAt(px, pz, feetY) { | |
| if (!cfg.collision) return null; // [e29] engine part: collision off = noclip | |
| if (levelOn()) return levelBlockedAt(px, pz, feetY); // [e30] walls/steps from the scene mesh | |
| const r = PLAYER_R; | |
| for (let i = 0; i < sceneNodes.length; i++) { | |
| const node = sceneNodes[i]; | |
| if (!node.pos) continue; | |
| if (node.name === 'pier' || node.name === 'ground' || node.name === 'marker') continue; | |
| if (node.climb || node.walkable) continue; | |
| if (node.hp !== undefined && node.hp <= 0 && !(node.dying > 0)) continue; | |
| const top = nodeTopY(node); | |
| if (feetY >= top - 0.02) continue; // on top — not a side block | |
| const s = node.scale; | |
| const sx = Array.isArray(s) ? s[0] : s; | |
| const sz = Array.isArray(s) ? s[2] : s; | |
| const halfX = sx * 0.5 + r, halfZ = sz * 0.5 + r; | |
| if (px > node.pos[0] - halfX && px < node.pos[0] + halfX && | |
| pz > node.pos[2] - halfZ && pz < node.pos[2] + halfZ) return top; | |
| } | |
| // parkour sides when below top | |
| for (let i = 0; i < parkourBlocks.length; i++) { | |
| const pl = parkourBlocks[i]; | |
| const top = pl.pos[1] + pl.scale[1] * 0.5; | |
| if (feetY >= top - 0.02) continue; | |
| const hx = pl.scale[0] * 0.5 + r, hz = pl.scale[2] * 0.5 + r; | |
| if (Math.abs(px - pl.pos[0]) < hx && Math.abs(pz - pl.pos[2]) < hz) return top; | |
| } | |
| // ladder body is climbable — not a hard block here | |
| return null; | |
| } | |
| // try move X then Z (per-axis), with step-up / mantle | |
| function moveAxis(ax, az) { | |
| const nx = cam.pos[0] + ax; | |
| const nz = cam.pos[2] + az; | |
| const hit = blockedAt(nx, nz, airY); | |
| if (hit == null) { | |
| cam.pos[0] = nx; cam.pos[2] = nz; | |
| return; | |
| } | |
| const rise = hit - airY; | |
| // auto step-up for small ledges | |
| if (rise > 0.02 && rise <= STEP_UP + 0.02 && onGround) { | |
| const hit2 = blockedAt(nx, nz, hit + 0.02); | |
| if (hit2 == null) { | |
| airY = hit; | |
| cam.pos[0] = nx; cam.pos[2] = nz; | |
| onGround = true; velY = 0; | |
| return; | |
| } | |
| } | |
| // mantle: press forward (or jump buffer) into taller ledge up to MANTLE_MAX | |
| const wantMantle = (move > 0.2 || jumpBufferTimer > 0) && rise > STEP_UP && rise <= MANTLE_MAX; | |
| if (wantMantle && (onGround || coyoteTimer > 0.02)) { | |
| const hit2 = blockedAt(nx, nz, hit + 0.05); | |
| if (hit2 == null) { | |
| airY = hit; | |
| cam.pos[0] = nx; cam.pos[2] = nz; | |
| velY = 0; onGround = true; jumpBufferTimer = 0; | |
| flashHint('MANTLE'); | |
| return; | |
| } | |
| } | |
| // blocked on this axis — leave position (other axis may still move) | |
| } | |
| const ox = cam.pos[0], oz = cam.pos[2]; | |
| moveAxis(dx, 0); | |
| moveAxis(0, dz); | |
| // if both axes alone failed but diagonal into a corner, stay put (already handled) | |
| // vertical: coyote + gravity + landing | |
| if (onGround) { | |
| coyoteTimer = COYOTE_TIME; | |
| } else { | |
| coyoteTimer = Math.max(0, coyoteTimer - dt); | |
| } | |
| if (jumpBufferTimer > 0) { | |
| jumpBufferTimer -= dt; | |
| if (tryJump()) { /* consumed */ } | |
| } | |
| if (!cfg.gravity) { // [e29] engine part: gravity off = fly (Space up, Ctrl down) | |
| velY = 0; jumpBufferTimer = 0; | |
| if (keys['Space']) airY += 3.2 * dt; | |
| if (keys['ControlLeft'] || keys['ControlRight']) airY -= 3.2 * dt; | |
| airY = levelOn() ? Math.max(level.collider.stats.min[1] - 5, Math.min(level.collider.stats.max[1] + 12, airY)) : Math.max(0, Math.min(12, airY)); // [e30] | |
| onGround = airY <= 0.001; coyoteTimer = COYOTE_TIME; wasOnGround = onGround; | |
| cam.pos[1] = standEye + airY; | |
| return; | |
| } | |
| const floorProbe = platformFloor(cam.pos[0], cam.pos[2], airY); | |
| if (!onGround || velY !== 0 || airY > floorProbe + 0.001) { | |
| velY -= GRAVITY * dt; | |
| airY += velY * dt; | |
| const floorNow = platformFloor(cam.pos[0], cam.pos[2], airY); | |
| if (velY <= 0 && airY <= floorNow + 0.14) { | |
| airY = floorNow; | |
| velY = 0; | |
| onGround = true; | |
| coyoteTimer = COYOTE_TIME; | |
| } else if (airY <= 0 && !levelOn()) { // [e30] no implicit y=0 floor inside a loaded level | |
| airY = 0; | |
| velY = 0; | |
| onGround = true; | |
| coyoteTimer = COYOTE_TIME; | |
| } else { | |
| onGround = false; | |
| } | |
| } else { | |
| airY = floorProbe; | |
| velY = 0; | |
| onGround = true; | |
| coyoteTimer = COYOTE_TIME; | |
| } | |
| // walked off ledge → start coyote (already set when onGround flips) | |
| if (wasOnGround && !onGround && velY <= 0) { | |
| coyoteTimer = COYOTE_TIME; | |
| } | |
| wasOnGround = onGround; | |
| cam.pos[1] = eye + airY; | |
| } | |
| function drawMesh(mesh, modelMat, baseColor, specColor, shininess){ | |
| const stride = mesh.stride || 32; | |
| gl.bindBuffer(gl.ARRAY_BUFFER, mesh.vbo); | |
| gl.enableVertexAttribArray(aPos); | |
| gl.enableVertexAttribArray(aNormal); | |
| if (aUV >= 0) gl.enableVertexAttribArray(aUV); | |
| gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, stride, 0); | |
| gl.vertexAttribPointer(aNormal, 3, gl.FLOAT, false, stride, 12); | |
| if (aUV >= 0) gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, stride, 24); | |
| bindDefaultTangent(); | |
| clearNormalMapUniforms(); | |
| gl.uniformMatrix4fv(uModel, false, modelMat); | |
| gl.uniform3fv(uBaseColor, baseColor); | |
| gl.uniform3fv(uSpecColor, cfg.lightExtras ? specColor : ZERO3); // [e29] engine part | |
| gl.uniform1f(uShininess, shininess); | |
| gl.drawArrays(gl.TRIANGLES, 0, mesh.count); | |
| } | |
| function drawMeshDepth(mesh, modelMat){ | |
| const stride = mesh.stride || 32; | |
| gl.bindBuffer(gl.ARRAY_BUFFER, mesh.vbo); | |
| gl.enableVertexAttribArray(d_aPos); | |
| gl.vertexAttribPointer(d_aPos, 3, gl.FLOAT, false, stride, 0); | |
| gl.uniformMatrix4fv(d_uModel, false, modelMat); | |
| gl.drawArrays(gl.TRIANGLES, 0, mesh.count); | |
| } | |
| function drawTexturedPart(part, rootModel){ | |
| const model = multiply(rootModel, part.local || identity()); | |
| const stride = part.mesh.stride || 32; | |
| gl.bindBuffer(gl.ARRAY_BUFFER, part.mesh.vbo); | |
| gl.enableVertexAttribArray(aPos); | |
| gl.enableVertexAttribArray(aNormal); | |
| if (aUV >= 0) gl.enableVertexAttribArray(aUV); | |
| gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, stride, 0); | |
| gl.vertexAttribPointer(aNormal, 3, gl.FLOAT, false, stride, 12); | |
| if (aUV >= 0) gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, stride, 24); | |
| if (part.mesh.hasTangent && aTangent >= 0) { | |
| gl.enableVertexAttribArray(aTangent); | |
| gl.vertexAttribPointer(aTangent, 4, gl.FLOAT, false, stride, 32); | |
| } else { | |
| bindDefaultTangent(); | |
| } | |
| gl.uniformMatrix4fv(uModel, false, model); | |
| gl.uniform3fv(uBaseColor, part.color || [1,1,1]); | |
| gl.uniform3fv(uSpecColor, cfg.lightExtras ? (part.spec || [0.35,0.35,0.35]) : ZERO3); // [e29] | |
| gl.uniform1f(uShininess, part.shin || 48); | |
| if (cfg.textures && part.useTex && part.tex) { // [e29] engine part | |
| gl.activeTexture(gl.TEXTURE1); | |
| gl.bindTexture(gl.TEXTURE_2D, part.tex); | |
| gl.uniform1i(uAlbedo, 1); | |
| gl.uniform1f(uUseTex, 1.0); | |
| } else { | |
| gl.uniform1f(uUseTex, 0.0); | |
| } | |
| if (cfg.textures && part.useNm && part.nm) { // [e29] engine part | |
| gl.activeTexture(gl.TEXTURE2); | |
| gl.bindTexture(gl.TEXTURE_2D, part.nm); | |
| gl.uniform1i(uNormalMap, 2); | |
| gl.uniform1f(uHasNormalMap, 1.0); | |
| gl.uniform1f(uNormalScale, part.nmScale != null ? part.nmScale : 1.0); | |
| } else { | |
| clearNormalMapUniforms(); | |
| } | |
| gl.drawArrays(gl.TRIANGLES, 0, part.mesh.count); | |
| gl.activeTexture(gl.TEXTURE1); | |
| gl.bindTexture(gl.TEXTURE_2D, albedoTex); | |
| gl.uniform1i(uAlbedo, 1); | |
| bindDefaultTangent(); | |
| clearNormalMapUniforms(); | |
| clearNormalMapUniforms(); | |
| if (aTangent >= 0) gl.disableVertexAttribArray(aTangent); | |
| } | |
| // ===== BEGIN GAME DATA: Breaker Bay level data / tuning (MIT like the rest; still inline here until the engine/game split in PLAN.md moves it to game/) ===== | |
| // Phase 25 lean scene: fewer decorative props; keep cover + parkour + shoot crates | |
| const LEAN_SCENE = true; | |
| const sceneNodes = [ | |
| { mesh: ground, model: identity(), color: [0.32,0.42,0.52], spec:[0.05,0.05,0.07], shin: 8, name:'ground', useTex:1 }, | |
| { mesh: triangle, model: null, color: [0.9,0.4,0.2], spec:[0.6,0.3,0.2], shin: 32, name:'marker' }, | |
| { mesh: crateMesh, model: null, color: [0.7,0.6,0.45], spec:[0.55,0.45,0.25], shin: 28, name:'crate0', pos:[-1.0,0.7,-3.0], scale:1.4, hp:3, maxHp:3, dying:0, pivot:'center' }, | |
| { mesh: crateMesh, model: null, color: [0.6,0.7,0.55], spec:[0.35,0.55,0.45], shin: 36, name:'crate1', pos:[1.4,0.6,-6.2], scale:1.2, hp:3, maxHp:3, dying:0, pivot:'center' }, | |
| { mesh: objMesh, model: null, color: [0.85,0.65,0.35], spec:[0.9,0.8,0.6], shin: 64, name:'objPyramid', pos:[-1.2,0.0,0.4], scale:1.0, solidR:1.25, hp:3, maxHp:3, dying:0, pivot:'base', aabbH:1.8 }, | |
| { mesh: crateMesh, model: null, color:[0.60,0.62,0.66], spec:[0.25,0.25,0.30], shin:22, name:'pier', pos:[0.0,0.1,-8.5], scale:[7.0,0.2,1.8], walkable:true }, | |
| { mesh: crateMesh, model: null, color:[0.88,0.18,0.14], spec:[0.55,0.2,0.15], shin:28, name:'redBox0', pos:[2.2,0.4,-1.8], scale:0.85, hp:2, maxHp:2, dying:0, pivot:'center' }, | |
| { mesh: crateMesh, model: null, color:[0.9,0.16,0.12], spec:[0.55,0.2,0.15], shin:28, name:'redBox1', pos:[-4.6,0.4,-4.4], scale:0.9, hp:2, maxHp:2, dying:0, pivot:'center' }, | |
| { mesh: crateMesh, model: null, color:[0.72,0.42,0.22], spec:[0.3,0.2,0.12], shin:20, name:'parkour0', pos:[3.6,0.25,3.4], scale:[1.2,0.50,1.2], walkable:true }, | |
| { mesh: crateMesh, model: null, color:[0.75,0.48,0.25], spec:[0.3,0.2,0.12], shin:20, name:'parkour1', pos:[4.9,0.80,2.7], scale:[1.15,0.70,1.15], walkable:true }, | |
| { mesh: crateMesh, model: null, color:[0.70,0.40,0.20], spec:[0.3,0.2,0.12], shin:20, name:'parkour2', pos:[6.1,1.40,1.9], scale:[1.2,0.70,1.2], walkable:true }, | |
| { mesh: crateMesh, model: null, color:[0.35,0.55,0.78], spec:[0.25,0.35,0.45], shin:24, name:'parkour3', pos:[7.2,2.10,1.0], scale:[1.25,0.70,1.25], walkable:true }, | |
| { mesh: crateMesh, model: null, color:[0.55,0.38,0.22], spec:[0.25,0.18,0.12], shin:18, name:'ladder', pos:[7.2,1.55,-0.15], scale:[0.34,3.1,0.34], climb:true }, | |
| { mesh: crateMesh, model: null, color:[0.30,0.70,0.45], spec:[0.25,0.40,0.30], shin:24, name:'ladderDeck', pos:[7.2,3.20,-0.9], scale:[2.0,0.22,1.8], walkable:true } | |
| ]; | |
| const childMarker = { mesh: triangle, parent:'crate0', local: [0,1.2,0], color:[1.0,0.9,0.3], spec:[1.0,0.9,0.5], shin: 80 }; | |
| // ===== END GAME DATA ===== | |
| function frustumCull(node){ | |
| if (!node.pos) return true; | |
| const dx = cam.pos[0] - node.pos[0]; | |
| const dz = cam.pos[2] - node.pos[2]; | |
| const dist = Math.hypot(dx, dz); | |
| if (dist > 35) return false; | |
| const sc = node.scale; | |
| const half = (Array.isArray(sc) ? Math.max(sc[0], sc[2]) : sc) * 0.9; | |
| if (Math.abs(cam.pos[0] - node.pos[0]) > 40 + half) return false; | |
| if (Math.abs(cam.pos[2] - node.pos[2]) > 40 + half) return false; | |
| return true; | |
| } | |
| function resolveCollision(){ | |
| if (!cfg.collision) return; // [e29] engine part: collision off = noclip | |
| if (levelOn()) { levelPushPlayer(); return; } // [e30] | |
| // Phase 28: height-gated side push — standing on top does not snag; resolve min-pen on XZ only. | |
| // Vertical (airY / velY) is never cleared here. | |
| const r = PLAYER_R; | |
| const feetY = airY; | |
| function solidTop(node) { | |
| const s = node.scale; | |
| const sy = Array.isArray(s) ? s[1] : (typeof s === 'number' ? s : 1); | |
| if (node.pivot === 'base' && node.aabbH != null) return node.pos[1] + node.aabbH; | |
| return node.pos[1] + sy * 0.5; | |
| } | |
| function pushOutXZ(cx, cz, hx, hz) { | |
| const minX = cx - hx, maxX = cx + hx; | |
| const minZ = cz - hz, maxZ = cz + hz; | |
| if (cam.pos[0] <= minX || cam.pos[0] >= maxX || cam.pos[2] <= minZ || cam.pos[2] >= maxZ) return; | |
| const dxL = cam.pos[0] - minX, dxR = maxX - cam.pos[0]; | |
| const dzB = cam.pos[2] - minZ, dzF = maxZ - cam.pos[2]; | |
| const m = Math.min(dxL, dxR, dzB, dzF); | |
| // prefer the axis of least penetration (stable edges) | |
| if (m === dxL) cam.pos[0] = minX; | |
| else if (m === dxR) cam.pos[0] = maxX; | |
| else if (m === dzB) cam.pos[2] = minZ; | |
| else cam.pos[2] = maxZ; | |
| } | |
| for (const node of sceneNodes){ | |
| if (!node.pos) continue; | |
| if (node.name === 'pier' || node.name === 'ground' || node.name === 'marker') continue; | |
| if (node.climb || node.walkable) continue; | |
| if (node.hp !== undefined && node.hp <= 0 && !(node.dying > 0)) continue; | |
| const top = solidTop(node); | |
| // on / above top → land via platformFloor; do not side-push (avoids mid-air stick) | |
| if (feetY >= top - 0.05) continue; | |
| const s = node.scale; | |
| const sx = Array.isArray(s) ? s[0] : s; | |
| const sz = Array.isArray(s) ? s[2] : s; | |
| pushOutXZ(node.pos[0], node.pos[2], sx * 0.5 + r, sz * 0.5 + r); | |
| } | |
| // bollard proxy when visible | |
| if (lookProp && lookProp.visible) { | |
| const bTop = lookProp.pos[1] + 1.15; | |
| if (feetY < bTop - 0.05) { | |
| pushOutXZ(lookProp.pos[0], lookProp.pos[2], 0.38 + r, 0.38 + r); | |
| } | |
| } | |
| if (!door.open) { | |
| const s = door.scale; | |
| const sx = Array.isArray(s) ? s[0] : s; | |
| const sz = Array.isArray(s) ? s[2] : s; | |
| pushOutXZ(door.pos[0], door.pos[2], sx * 0.5 + r, sz * 0.5 + r); | |
| } | |
| } | |
| function shareAmmoIntoMag(){ | |
| const need = MAG_CAP - ammoMag; | |
| if (need <= 0 || ammoReserve <= 0) return 0; | |
| const take = Math.min(need, ammoReserve); | |
| ammoMag += take; | |
| ammoReserve -= take; | |
| return take; | |
| } | |
| function checkAmmoPickups(){ | |
| for (const p of ammoPickups){ | |
| if (!p.alive) continue; | |
| const dx = cam.pos[0] - p.pos[0]; | |
| const dz = cam.pos[2] - p.pos[2]; | |
| if (Math.hypot(dx, dz) < 1.45){ | |
| ammoReserve += p.amount; | |
| p.alive = false; | |
| const shared = shareAmmoIntoMag(); | |
| sfxPickup(); flashHint('AMMO ' + ammoMag + '/' + ammoReserve + (shared ? (' MAG+' + shared) : (' RES+' + p.amount))); | |
| } | |
| } | |
| } | |
| let targets = sceneNodes.filter(n => n.hp !== undefined); | |
| function refreshTargets(){ | |
| targets = sceneNodes.filter(n => n.hp !== undefined); | |
| } | |
| const hintEl = document.createElement('div'); | |
| hintEl.style.position='fixed'; | |
| hintEl.style.left='50%'; | |
| hintEl.style.top='18%'; | |
| hintEl.style.transform='translateX(-50%)'; | |
| hintEl.style.color='#fff'; | |
| hintEl.style.fontSize='14px'; | |
| hintEl.style.background='rgba(0,0,0,.55)'; | |
| hintEl.style.padding='6px 10px'; | |
| hintEl.style.borderRadius='8px'; | |
| hintEl.style.opacity='0'; | |
| hintEl.style.transition='opacity .3s'; | |
| hintEl.style.pointerEvents='none'; | |
| document.body.appendChild(hintEl); | |
| let lastHintText = ''; | |
| function flashHint(text){ | |
| lastHintText = text; | |
| hintEl.textContent = text; | |
| hintEl.style.opacity='1'; | |
| setTimeout(()=>{ hintEl.style.opacity='0'; }, 1500); | |
| } | |
| // Phase 27 — Muse pick: pierMooringBollard (Mooring Bollard), in-engine pos+norm+uv | |
| function createPierMooringBollardMesh() { | |
| const pos = []; | |
| const norm = []; | |
| const uvs = []; | |
| function pushTri(ax,ay,az, bx,by,bz, cx,cy,cz, nx,ny,nz, ua,va, ub,vb, uc,vc) { | |
| pos.push(ax,ay,az, bx,by,bz, cx,cy,cz); | |
| norm.push(nx,ny,nz, nx,ny,nz, nx,ny,nz); | |
| uvs.push(ua,va, ub,vb, uc,vc); | |
| } | |
| function addCylinder(r, y0, y1, segs, v0, v1) { | |
| for (let i = 0; i < segs; i++) { | |
| const a0 = (i / segs) * Math.PI * 2; | |
| const a1 = ((i + 1) / segs) * Math.PI * 2; | |
| const c0 = Math.cos(a0), s0 = Math.sin(a0); | |
| const c1 = Math.cos(a1), s1 = Math.sin(a1); | |
| const x0 = c0 * r, z0 = s0 * r; | |
| const x1 = c1 * r, z1 = s1 * r; | |
| const u0 = i / segs, u1 = (i + 1) / segs; | |
| // side | |
| pushTri(x0,y0,z0, x1,y0,z1, x1,y1,z1, c0,0,s0, u0,v0, u1,v0, u1,v1); | |
| pushTri(x0,y0,z0, x1,y1,z1, x0,y1,z0, c0,0,s0, u0,v0, u1,v1, u0,v1); | |
| // top cap | |
| pushTri(0,y1,0, x0,y1,z0, x1,y1,z1, 0,1,0, 0.5,0.5, 0.5+c0*0.5,0.5+s0*0.5, 0.5+c1*0.5,0.5+s1*0.5); | |
| // bottom cap | |
| pushTri(0,y0,0, x1,y0,z1, x0,y0,z0, 0,-1,0, 0.5,0.5, 0.5+c1*0.5,0.5+s1*0.5, 0.5+c0*0.5,0.5+s0*0.5); | |
| } | |
| } | |
| function addBox(cx,cy,cz, sx,sy,sz, u0,v0,u1,v1) { | |
| const x0 = cx - sx*0.5, x1 = cx + sx*0.5; | |
| const y0 = cy - sy*0.5, y1 = cy + sy*0.5; | |
| const z0 = cz - sz*0.5, z1 = cz + sz*0.5; | |
| // +Z | |
| pushTri(x0,y0,z1, x1,y0,z1, x1,y1,z1, 0,0,1, u0,v0, u1,v0, u1,v1); | |
| pushTri(x0,y0,z1, x1,y1,z1, x0,y1,z1, 0,0,1, u0,v0, u1,v1, u0,v1); | |
| // -Z | |
| pushTri(x1,y0,z0, x0,y0,z0, x0,y1,z0, 0,0,-1, u0,v0, u1,v0, u1,v1); | |
| pushTri(x1,y0,z0, x0,y1,z0, x1,y1,z0, 0,0,-1, u0,v0, u1,v1, u0,v1); | |
| // +X | |
| pushTri(x1,y0,z1, x1,y0,z0, x1,y1,z0, 1,0,0, u0,v0, u1,v0, u1,v1); | |
| pushTri(x1,y0,z1, x1,y1,z0, x1,y1,z1, 1,0,0, u0,v0, u1,v1, u0,v1); | |
| // -X | |
| pushTri(x0,y0,z0, x0,y0,z1, x0,y1,z1, -1,0,0, u0,v0, u1,v0, u1,v1); | |
| pushTri(x0,y0,z0, x0,y1,z1, x0,y1,z0, -1,0,0, u0,v0, u1,v1, u0,v1); | |
| // +Y | |
| pushTri(x0,y1,z1, x1,y1,z1, x1,y1,z0, 0,1,0, u0,v0, u1,v0, u1,v1); | |
| pushTri(x0,y1,z1, x1,y1,z0, x0,y1,z0, 0,1,0, u0,v0, u1,v1, u0,v1); | |
| // -Y | |
| pushTri(x0,y0,z0, x1,y0,z0, x1,y0,z1, 0,-1,0, u0,v0, u1,v0, u1,v1); | |
| pushTri(x0,y0,z0, x1,y0,z1, x0,y0,z1, 0,-1,0, u0,v0, u1,v1, u0,v1); | |
| } | |
| const segs = 16; | |
| addCylinder(0.30, 0.00, 0.15, segs, 0.00, 0.12); // base | |
| addCylinder(0.22, 0.15, 1.05, segs, 0.12, 0.88); // shaft | |
| addCylinder(0.28, 1.05, 1.20, segs, 0.88, 1.00); // cap | |
| // horns at shaft top (~y=1.05), along +/- Z | |
| addBox(0.00, 1.05, 0.25, 0.12, 0.12, 0.50, 0.0, 0.0, 1.0, 1.0); | |
| addBox(0.00, 1.05, -0.25, 0.12, 0.12, 0.50, 0.0, 0.0, 1.0, 1.0); | |
| const tans = computeTangentsFromUV(pos, norm, uvs); | |
| return createMeshWithTangents(new Float32Array(pos), new Float32Array(norm), new Float32Array(uvs), tans); | |
| } | |
| const LOOK_TOYCAR_URL = 'models/look/khronos-toy-car.glb'; | |
| const pierMooringBollardMesh = createPierMooringBollardMesh(); | |
| const lookProp = { | |
| parts: [], | |
| authoredParts: [{ | |
| mesh: pierMooringBollardMesh, | |
| color: [0.28, 0.30, 0.34], | |
| spec: [0.48, 0.45, 0.40], | |
| shin: 72, | |
| useTex: false, | |
| useNm: false, | |
| local: null | |
| }], | |
| pos: [2.4, 0.0, -4.2], | |
| yaw: 0.0, | |
| visible: true, | |
| loaded: true, | |
| name: 'pierMooringBollard' | |
| }; | |
| function lookPropRootModel(){ | |
| // T * R: yaw in place, then world translation (avoid pre-existing translate quirks) | |
| let m = rotateY(identity(), lookProp.yaw); | |
| m[12] = lookProp.pos[0]; | |
| m[13] = lookProp.pos[1]; | |
| m[14] = lookProp.pos[2]; | |
| return m; | |
| } | |
| function applyLookParts(parts){ | |
| lookProp.parts = parts || []; | |
| lookProp.loaded = lookProp.parts.length > 0 || (lookProp.authoredParts && lookProp.authoredParts.length > 0); | |
| lookProp.visible = true; | |
| } | |
| function loadLookGlbUrl(url, hintOk){ | |
| flashHint('LOOK LOAD…'); | |
| return fetch(url).then(function(r){ | |
| if (!r.ok) throw new Error('HTTP ' + r.status); | |
| return r.arrayBuffer(); | |
| }).then(function(ab){ | |
| return loadGLBTextured(ab); | |
| }).then(function(res){ | |
| applyLookParts(res.parts); | |
| flashHint(hintOk || ('LOOK ' + res.parts.length + ' MESH')); | |
| return res; | |
| }).catch(function(err){ | |
| console.error(err); | |
| flashHint('LOOK FAIL'); | |
| }); | |
| } | |
| function toggleLookProp(){ | |
| // Phase 27: authored Mooring Bollard is the default look prop (no ToyCar). | |
| // Key L still picks a GLB into lookProp.parts; authoredParts stay. | |
| lookProp.visible = !lookProp.visible; | |
| cfg.bollard = lookProp.visible; // [e29] | |
| flashHint(lookProp.visible ? 'LOOK ON · BOLLARD' : 'LOOK OFF'); | |
| } | |
| // Auto-load showroom ToyCar look bar (CC0) when served on 8780 | |
| // no cars in bay scene (Paul) — ToyCar auto-load off; Key L for bay-fitting GLB+NM | |
| // loadLookGlbUrl(LOOK_TOYCAR_URL, 'TOYCAR LOOK'); | |
| const glbFileInput = document.createElement('input'); | |
| glbFileInput.type = 'file'; | |
| glbFileInput.accept = '.glb,model/gltf-binary'; | |
| glbFileInput.style.display = 'none'; | |
| document.body.appendChild(glbFileInput); | |
| function openGlbPicker(){ | |
| if (typeof levelOn === 'function' && levelOn()) { flashHint('SHIFT+L PROP IS OFF IN A LOADED LEVEL · TAB → MODEL IMPORT'); return; } // [lookB-fix] | |
| try { glbFileInput.value = ''; } catch (_) {} | |
| glbFileInput.click(); | |
| } | |
| function applyGlbImport(mesh){ | |
| const b = glbLastBounds || { size:[1,1,1], min:[0,0,0] }; | |
| const h = Math.max(b.size[1] || 1, 1e-4); | |
| const sc = 1.5 / h; | |
| let node = sceneNodes.find(n => n.name === 'glbImport'); | |
| if (!node) { | |
| node = { | |
| mesh: mesh, | |
| model: null, | |
| color: [0.72, 0.68, 0.62], | |
| spec: [0.35, 0.32, 0.28], | |
| shin: 36, | |
| name: 'glbImport', | |
| pos: [1.5, 0.0, -3.5], | |
| scale: sc, | |
| hp: 3, | |
| maxHp: 3, | |
| dying: 0, | |
| pivot: 'base', | |
| aabbH: h | |
| }; | |
| sceneNodes.push(node); | |
| } else { | |
| node.mesh = mesh; | |
| node.scale = sc; | |
| node.aabbH = h; | |
| node.hp = 3; | |
| node.maxHp = 3; | |
| node.dying = 0; | |
| node.pos = [1.5, 0.0, -3.5]; | |
| node.color = [0.72, 0.68, 0.62]; | |
| } | |
| refreshTargets(); | |
| return node; | |
| } | |
| glbFileInput.addEventListener('change', function(){ | |
| const file = glbFileInput.files && glbFileInput.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = function(){ | |
| loadGLBTextured(reader.result).then(function(res){ | |
| applyLookParts(res.parts); | |
| // [lookB-fix] textured load worked: no extra grey fallback copy (it ignored node transforms and drew upside-down) | |
| const oldFb = sceneNodes.findIndex(n => n.name === 'glbImport'); | |
| if (oldFb >= 0) { sceneNodes.splice(oldFb, 1); refreshTargets(); } | |
| flashHint('GLB LOOK ' + res.parts.length); | |
| }).catch(function(err){ | |
| console.error(err); | |
| try { | |
| const mesh = parseGLB(reader.result); | |
| applyGlbImport(mesh); | |
| flashHint('GLB LOADED (UNTEXTURED FALLBACK)'); // [lookB-fix] | |
| } catch (e2) { | |
| console.error(e2); | |
| flashHint('GLB FAIL'); | |
| } | |
| }); | |
| }; | |
| reader.onerror = function(){ flashHint('GLB FAIL'); }; | |
| reader.readAsArrayBuffer(file); | |
| }); | |
| function rayAABB(origin, dir, min, max){ | |
| let tmin = -Infinity, tmax = Infinity; | |
| for (let i=0;i<3;i++){ | |
| if (Math.abs(dir[i]) < 1e-6){ | |
| if (origin[i] < min[i] || origin[i] > max[i]) return null; | |
| } else { | |
| const ood = 1.0 / dir[i]; | |
| let t1 = (min[i] - origin[i]) * ood; | |
| let t2 = (max[i] - origin[i]) * ood; | |
| if (t1 > t2) [t1,t2] = [t2,t1]; | |
| tmin = Math.max(tmin, t1); | |
| tmax = Math.min(tmax, t2); | |
| if (tmin > tmax) return null; | |
| } | |
| } | |
| if (tmax < 0) return null; | |
| return tmin > 0 ? tmin : tmax; | |
| } | |
| function nodeHitAABB(node){ | |
| if (!node || !node.pos) return null; | |
| const s = node.scale; | |
| const sx = Array.isArray(s) ? s[0] : (s || 1); | |
| const sy = Array.isArray(s) ? s[1] : (s || 1); | |
| const sz = Array.isArray(s) ? s[2] : (s || 1); | |
| const cx = node.pos[0], cz = node.pos[2]; | |
| const nm = node.name || ''; | |
| if (node.pivot === 'base') { | |
| const sc = Array.isArray(s) ? 1.0 : (s || 1); | |
| const h = (node.aabbH || sy) * sc; | |
| const y0 = node.pos[1]; | |
| let hx = sx * 0.5, hz = sz * 0.5; | |
| if (typeof node.solidR === 'number' && node.solidR > 0) { | |
| hx = node.solidR * sc; hz = node.solidR * sc; | |
| } else if (/objPyramid/i.test(nm)) { hx = 1.25 * sc; hz = 1.25 * sc; } | |
| else if (/objRock/i.test(nm)) { hx = 0.65 * sc; hz = 0.65 * sc; } | |
| return [[cx - hx, y0, cz - hz], [cx + hx, y0 + h, cz + hz]]; | |
| } | |
| const cy = node.pos[1]; | |
| const y0 = Math.max(0, cy - sy*0.5); | |
| return [[cx - sx*0.5, y0, cz - sz*0.5], [cx + sx*0.5, y0 + sy, cz + sz*0.5]]; | |
| } | |
| function tryShoot(){ | |
| if (!cfg.playerShoot) { flashHint('SHOOTING OFF (config)'); return; } // [e29] | |
| if (reloading) { flashHint('RELOADING'); return; } | |
| if (shootCooldown > 0) return; | |
| if (ammoMag <= 0){ | |
| if (!reloading && ammoReserve > 0) { | |
| reloading = true; | |
| reloadTimer = 1.0; | |
| flashHint('RELOADING'); | |
| sfxReloadStart(); | |
| return; | |
| } | |
| flashHint('NO AMMO'); | |
| shootCooldown = 0.15; | |
| return; | |
| } | |
| if (!cfg.infiniteAmmo) ammoMag -= 1; // [e29] ammo-limit toggle | |
| shootCooldown = 0.25; | |
| muzzleFlash = 0.12; | |
| sfxShoot(); | |
| const eye = [cam.pos[0], cam.pos[1], cam.pos[2]]; | |
| const cp = Math.cos(cam.pitch); | |
| const dir = [ | |
| Math.sin(cam.yaw) * cp, | |
| Math.sin(cam.pitch), | |
| -Math.cos(cam.yaw) * cp | |
| ]; | |
| const len = Math.hypot(dir[0], dir[1], dir[2]) || 1; | |
| dir[0]/=len; dir[1]/=len; dir[2]/=len; | |
| let best = null, bestT = Infinity; | |
| for (const node of targets){ | |
| if (node.hp <= 0 || (node.dying && node.dying > 0)) continue; | |
| const box = nodeHitAABB(node); | |
| if (!box) continue; | |
| let t = rayAABB(eye, dir, box[0], box[1]); | |
| if (t !== null && node.name === 'objPyramid') t = pyramidRayT(node, eye, dir); // [e30] exact pyramid, not its square AABB | |
| if (t !== null && t < bestT){ | |
| bestT = t; best = node; | |
| } | |
| } | |
| if (cfg.enemies && hostile.alive && hostile.dying <= 0) { | |
| const s = hostile.scale; | |
| const cy = hostile.baseY + s * 0.5; | |
| const cx = hostile.pos[0], cz = hostile.pos[2]; | |
| const min = [cx - s*0.5, cy - s*0.5, cz - s*0.5]; | |
| const max = [cx + s*0.5, cy + s*0.5, cz + s*0.5]; | |
| const t = e31On() ? e31Ray(hostile, eye, dir) : rayAABB(eye, dir, min, max); // [e31] head sphere / body capsule | |
| if (t !== null && t < bestT) { | |
| bestT = t; best = { isHostile:true }; | |
| } | |
| } | |
| if (cfg.enemies && patrolHostile.alive && patrolHostile.dying <= 0) { | |
| const s = patrolHostile.scale; | |
| const cy = patrolHostile.baseY + s * 0.5; | |
| const cx = patrolHostile.pos[0], cz = patrolHostile.pos[2]; | |
| const min = [cx - s*0.5, cy - s*0.5, cz - s*0.5]; | |
| const max = [cx + s*0.5, cy + s*0.5, cz + s*0.5]; | |
| const t = e31On() ? e31Ray(patrolHostile, eye, dir) : rayAABB(eye, dir, min, max); // [e31] head sphere / body capsule | |
| if (t !== null && t < bestT) { | |
| bestT = t; best = { isPatrol:true }; | |
| } | |
| } | |
| for (let di = 0; di < dogs.length; di++){ | |
| const d = dogs[di]; | |
| if (!d.alive || !cfg.dogs) continue; | |
| const sx = d.scale, sy = d.scale * 0.6, sz = d.scale * 1.2; | |
| const cx = d.pos[0], cy = d.pos[1], cz = d.pos[2]; | |
| const min = [cx - sx*0.5, cy - sy*0.5, cz - sz*0.5]; | |
| const max = [cx + sx*0.5, cy + sy*0.5, cz + sz*0.5]; | |
| const t = e32DogRay(d, eye, dir, min, max); // [e32] capsule when the dog uses the rig | |
| if (t !== null && t < bestT) { | |
| bestT = t; best = { isDog:true, dogIndex: di }; | |
| } | |
| } | |
| for (let li = 0; li < lamps.length; li++){ | |
| const l = lamps[li]; | |
| if (l.broken) continue; | |
| const tip = lampBulbTip(l); | |
| const hs = Math.max(l.bulbScale[0], l.bulbScale[1], l.bulbScale[2]) * 1.1 + 0.25; | |
| const postW = Math.max(l.postScale[0], l.postScale[2]) * 0.5 + 0.22; | |
| const min = [l.pos[0] - postW, l.pos[1], l.pos[2] - postW]; | |
| const max = [l.pos[0] + postW, tip[1] + hs, l.pos[2] + postW]; | |
| const tt = rayAABB(eye, dir, min, max); | |
| if (tt !== null && tt < bestT) { | |
| bestT = tt; best = { isLamp:true, lampIndex: li }; | |
| } | |
| } | |
| const skipImpact = { ground:1, marker:1, pier:1 }; | |
| for (const node of sceneNodes){ | |
| if (!node.pos || skipImpact[node.name]) continue; | |
| if (node.hp !== undefined) continue; | |
| const box = nodeHitAABB(node); | |
| if (!box) continue; | |
| const t = rayAABB(eye, dir, box[0], box[1]); | |
| if (t !== null && t < bestT) { | |
| bestT = t; best = { isImpact:true, node: node }; | |
| } | |
| } | |
| if (!door.open) { | |
| const box = nodeHitAABB(door); | |
| if (box) { | |
| const t = rayAABB(eye, dir, box[0], box[1]); | |
| if (t !== null && t < bestT) { | |
| bestT = t; best = { isImpact:true, node: door }; | |
| } | |
| } | |
| } | |
| { const lh = levelShotTest(eye, dir, bestT); if (lh) { bestT = lh.t; best = { isLevel: true, hit: lh }; } } // [e30] scene mesh stops bullets | |
| recordShot(eye, dir, bestT); // [e30] | |
| if (best){ | |
| sfxHit(); | |
| if (best.isHostile) { | |
| const st = resolveHitStyle('hostile'); | |
| showHitMarker(st); | |
| applyObjectFlash(hostile, st); | |
| hostile.hp -= e31Damage(hostile); // [e31] headshot x3 | |
| score += 2; | |
| if (hostile.hp <= 0) { | |
| if (hostile.dying <= 0) { | |
| hostile.dying = HOSTILE_DEATH_TOTAL; | |
| hostile.deathBoomPending = true; | |
| hostile.tipSign = (hostile.pos[0] - cam.pos[0]) >= 0 ? 1 : -1; | |
| } | |
| flashHint('HOSTILE DOWN'); | |
| } else { | |
| flashHint(st.hint); | |
| } | |
| } else if (best.isPatrol) { | |
| const st = resolveHitStyle('patrol'); | |
| showHitMarker(st); | |
| applyObjectFlash(patrolHostile, st); | |
| patrolHostile.hp -= e31Damage(patrolHostile); // [e31] headshot x3 | |
| score += 2; | |
| if (patrolHostile.hp <= 0) { | |
| if (patrolHostile.dying <= 0) { | |
| patrolHostile.dying = HOSTILE_DEATH_TOTAL; | |
| patrolHostile.deathBoomPending = true; | |
| patrolHostile.tipSign = (patrolHostile.pos[0] - cam.pos[0]) >= 0 ? 1 : -1; | |
| } | |
| flashHint('PATROL DOWN'); | |
| } else { | |
| flashHint(st.hint); | |
| } | |
| } else if (best.isDog) { | |
| const d = dogs[best.dogIndex]; | |
| const st = resolveHitStyle('dog'); | |
| showHitMarker(st); | |
| applyObjectFlash(d, st); | |
| score += 1; | |
| d.hp = (d.hp == null ? 2 : d.hp) - 1; | |
| const ax = d.pos[0] - cam.pos[0], az = d.pos[2] - cam.pos[2]; | |
| const al = Math.hypot(ax, az) || 1; | |
| d.target[0] = d.pos[0] + (ax/al) * 4.0; | |
| d.target[2] = d.pos[2] + (az/al) * 4.0; | |
| d.wanderTimer = 1.2; | |
| d.speed = Math.max(d.speed, 2.4); | |
| if (d.hp <= 0) { | |
| d.alive = false; | |
| flashHint('DOG DOWN'); | |
| } else { | |
| flashHint(st.hint); | |
| } | |
| } else if (best.isLamp) { | |
| const l = lamps[best.lampIndex]; | |
| const st = resolveHitStyle('lamp'); | |
| showHitMarker(st); | |
| applyObjectFlash(l, st); | |
| l.hitFlash = 0.4; | |
| l.broken = true; | |
| l.on = false; | |
| score += 1; | |
| flashHint(nightMode ? 'LAMP HIT — OUT' : 'LAMP HIT'); | |
| } else if (best.isLevel) { e30LevelHit(best); // [e30] | |
| } else if (best.isImpact) { | |
| const n = best.node; | |
| const st = resolveHitStyle('impact', n); | |
| showHitMarker(st); | |
| applyObjectFlash(n, st); | |
| flashHint(st.hint); | |
| } else { | |
| const st = resolveHitStyle('target', best); | |
| showHitMarker(st); | |
| applyObjectFlash(best, st); | |
| best.hp -= 1; | |
| score += 1; | |
| if (best.hp <= 0){ | |
| if (!best.dying) best.dying = cfg.deathFx ? 0.55 : 0.001; // [e29] | |
| } | |
| flashHint(st.hint); | |
| } | |
| } | |
| e31AfterShot(best); // [e31] | |
| } | |
| function groundedBoxModel(posX,posZ,scaleXZ,scaleY,baseY){ | |
| let m = identity(); | |
| m[0]=scaleXZ; m[5]=scaleY; m[10]=scaleXZ; | |
| m[12]=posX; m[13]=baseY + scaleY * 0.5; m[14]=posZ; | |
| return m; | |
| } | |
| function hostileDeathPhase(h){ | |
| if (!(h.dying > 0)) return null; | |
| if (h.dying > HOSTILE_DEATH_BOOM) { | |
| const fallLeft = h.dying - HOSTILE_DEATH_BOOM; | |
| const u = 1.0 - Math.max(0, Math.min(1, fallLeft / HOSTILE_DEATH_FALL)); | |
| return { phase: 'fall', u: u }; | |
| } | |
| const u = 1.0 - Math.max(0, Math.min(1, h.dying / HOSTILE_DEATH_BOOM)); | |
| return { phase: 'boom', u: u }; | |
| } | |
| function hostileDrawScale(h){ | |
| let dmg = 1.0 - Math.max(0, 5 - Math.max(h.hp, 0)) * 0.05; | |
| const ph = hostileDeathPhase(h); | |
| if (!ph) { | |
| return { xz: Math.max(0.02, h.scale * dmg), y: Math.max(0.02, h.scale * dmg), tip: 0, sink: 0, flash: false }; | |
| } | |
| const tipSign = (h.tipSign == null ? 1 : h.tipSign); | |
| if (ph.phase === 'fall') { | |
| // ragdoll-lite: tip + sink while body still readable | |
| const tip = tipSign * ph.u * (Math.PI * 0.48); | |
| const sink = ph.u * h.scale * 0.38; | |
| const squash = 1.0 - ph.u * 0.22; | |
| return { | |
| xz: Math.max(0.02, h.scale * dmg * (0.92 + 0.08 * (1.0 - ph.u))), | |
| y: Math.max(0.02, h.scale * dmg * squash), | |
| tip: tip, | |
| sink: sink, | |
| flash: false | |
| }; | |
| } | |
| // boom: expand pulse then fade-out / despawn | |
| const pulse = 1.0 + ph.u * 1.75; | |
| const fade = Math.max(0.02, 1.0 - ph.u); | |
| return { | |
| xz: Math.max(0.02, h.scale * dmg * pulse * fade), | |
| y: Math.max(0.02, h.scale * dmg * 0.32 * pulse * fade), | |
| tip: tipSign * (Math.PI * 0.48), | |
| sink: h.scale * 0.38, | |
| flash: true | |
| }; | |
| } | |
| function hostileModel(h){ | |
| const hs = hostileDrawScale(h); | |
| const baseY = (h.baseY || 0) - (hs.sink || 0); | |
| let m = groundedBoxModel(h.pos[0], h.pos[2], hs.xz, hs.y, baseY); | |
| if (hs.tip) m = rotateX(m, hs.tip); | |
| return { m: m, hs: hs }; | |
| } | |
| function tickHostileDeath(h, dt, downHint){ | |
| if (!(h.dying > 0)) return false; | |
| if (!cfg.deathFx) { h.dying = 0; h.alive = false; h.deathBoomPending = false; return true; } // [e29] | |
| const wasFall = h.dying > HOSTILE_DEATH_BOOM; | |
| h.dying -= dt; | |
| if (wasFall && h.dying <= HOSTILE_DEATH_BOOM && h.deathBoomPending) { | |
| h.deathBoomPending = false; | |
| h.hitFlash = 0.22; | |
| h.hitFlashColor = [1.0, 0.9, 0.35]; | |
| flashHint('BOOM'); | |
| try { sfxHit(); } catch (_) {} | |
| } | |
| if (h.dying <= 0) { | |
| h.dying = 0; | |
| h.alive = false; | |
| h.deathBoomPending = false; | |
| } | |
| return true; | |
| } | |
| // Phase 29: solid world AABBs for AI — same spirit as player side push / dog solids. | |
| // Skip floors (ground/pier/marker) and climbables; collide buildings/props/door/bollard. | |
| function hostileSkipsNode(node){ | |
| if (!node || !node.pos) return true; | |
| const nm = node.name || ''; | |
| if (nm === 'pier' || nm === 'ground' || nm === 'marker') return true; | |
| if (node.climb || node.walkable) return true; | |
| return false; | |
| } | |
| function pushOutOfNodes(pos, bodyHalf){ | |
| function addBox(boxes, minX, maxX, minZ, maxZ){ | |
| boxes.push([minX, maxX, minZ, maxZ]); | |
| } | |
| const boxes = []; | |
| for (const node of sceneNodes){ | |
| if (hostileSkipsNode(node)) continue; | |
| if (node.hp !== undefined && node.hp <= 0 && !(node.dying > 0)) continue; | |
| const box = nodeHitAABB(node); | |
| if (!box) continue; | |
| const pad = bodyHalf; | |
| addBox(boxes, box[0][0] - pad, box[1][0] + pad, box[0][2] - pad, box[1][2] + pad); | |
| } | |
| if (!door.open) { | |
| const box = nodeHitAABB(door); | |
| if (box) { | |
| const pad = bodyHalf; | |
| addBox(boxes, box[0][0] - pad, box[1][0] + pad, box[0][2] - pad, box[1][2] + pad); | |
| } | |
| } | |
| if (lookProp && lookProp.visible) { | |
| const pad = bodyHalf + 0.38; | |
| addBox(boxes, lookProp.pos[0] - pad, lookProp.pos[0] + pad, lookProp.pos[2] - pad, lookProp.pos[2] + pad); | |
| } | |
| for (let pass = 0; pass < 5; pass++){ | |
| let moved = false; | |
| for (const b of boxes){ | |
| const minX = b[0], maxX = b[1], minZ = b[2], maxZ = b[3]; | |
| if (pos[0] <= minX || pos[0] >= maxX || pos[2] <= minZ || pos[2] >= maxZ) continue; | |
| const dxL = pos[0] - minX, dxR = maxX - pos[0]; | |
| const dzB = pos[2] - minZ, dzF = maxZ - pos[2]; | |
| const m = Math.min(dxL, dxR, dzB, dzF); | |
| if (m === dxL) pos[0] = minX; | |
| else if (m === dxR) pos[0] = maxX; | |
| else if (m === dzB) pos[2] = minZ; | |
| else pos[2] = maxZ; | |
| moved = true; | |
| } | |
| if (!moved) break; | |
| } | |
| } | |
| function moveHostileXZ(h, tryX, tryZ, half, step){ | |
| if (levelOn()) { levelMoveChar(h, tryX, tryZ, e31R(h), e31H(h), false); return; } // [e30]; [e31] fitted capsule | |
| const ox = h.pos[0], oz = h.pos[2]; | |
| // substep to reduce tunneling through thin AABBs | |
| const dist = Math.hypot(tryX - ox, tryZ - oz); | |
| const nSub = Math.max(1, Math.min(4, Math.ceil(dist / Math.max(0.12, half * 0.35)))); | |
| let cx = ox, cz = oz; | |
| const dx = (tryX - ox) / nSub, dz = (tryZ - oz) / nSub; | |
| for (let i = 0; i < nSub; i++){ | |
| const px = cx, pz = cz; | |
| cx = px + dx; cz = pz + dz; | |
| h.pos[0] = cx; h.pos[2] = cz; | |
| pushOutOfNodes(h.pos, half); | |
| if (Math.hypot(h.pos[0] - px, h.pos[2] - pz) < Math.hypot(dx, dz) * 0.18) { | |
| // blocked — try axes separately (player-style) | |
| h.pos[0] = px + dx; h.pos[2] = pz; | |
| pushOutOfNodes(h.pos, half); | |
| const xOk = Math.abs(h.pos[0] - px) > Math.abs(dx) * 0.2; | |
| if (!xOk) { | |
| h.pos[0] = px; h.pos[2] = pz + dz; | |
| pushOutOfNodes(h.pos, half); | |
| } | |
| } | |
| cx = h.pos[0]; cz = h.pos[2]; | |
| } | |
| } | |
| function updateHostile(dt){ | |
| if (!hostile.alive) return; | |
| if (tickHostileDeath(hostile, dt, 'HOSTILE DOWN')) return; | |
| const px = cam.pos[0], pz = cam.pos[2]; | |
| const hx = hostile.pos[0], hz = hostile.pos[2]; | |
| let dx = px - hx, dz = pz - hz; | |
| const distXZ = Math.hypot(dx, dz); | |
| const r = 0.28; | |
| const half = hostile.scale * 0.5 + r; | |
| if (!hostile.alerted) { | |
| if (distXZ <= (hostile.aggroRange || 7)) { | |
| hostile.alerted = true; | |
| flashHint('HOSTILE'); | |
| } else { | |
| pushOutOfNodes(hostile.pos, half); | |
| return; | |
| } | |
| } else if (distXZ > (hostile.loseRange || 14)) { | |
| hostile.alerted = false; | |
| pushOutOfNodes(hostile.pos, half); | |
| return; | |
| } | |
| if (distXZ > 0.01) { | |
| const step = e31ChaseSpeed(hostile, distXZ) * dt; // [e31] animated: run when far | |
| const nx = dx / distXZ, nz = dz / distXZ; | |
| const tryX = hostile.pos[0] + nx * step; | |
| const tryZ = hostile.pos[2] + nz * step; | |
| moveHostileXZ(hostile, tryX, tryZ, half, step); | |
| } else { | |
| pushOutOfNodes(hostile.pos, half); | |
| } | |
| separateFromDogs(hostile.pos, hostile.scale * 0.55); | |
| pushOutOfNodes(hostile.pos, half); | |
| if (hurtCooldown > 0) hurtCooldown -= dt; | |
| const d2 = Math.hypot(hostile.pos[0] - px, hostile.pos[2] - pz); | |
| if (cfg.enemyAttack && d2 < 1.2 && hurtCooldown <= 0) { // [e29] toggle | |
| playerHP -= 1; | |
| hurtCooldown = 0.6; | |
| hurtFlash = 0.25; | |
| flashHint('HURT'); sfxHurt(); | |
| if (playerHP <= 0) { | |
| // Phase 29: brief down beat then recover (no full death screen yet) | |
| hurtFlash = 0.55; | |
| flashHint('DOWN'); | |
| playerHP = 5; | |
| hurtCooldown = 1.5; | |
| const ax = cam.pos[0] - hostile.pos[0], az = cam.pos[2] - hostile.pos[2]; | |
| const al = Math.hypot(ax, az) || 1; | |
| hostile.pos[0] -= (ax/al) * 2.2; | |
| hostile.pos[2] -= (az/al) * 2.2; | |
| pushOutOfNodes(hostile.pos, half); | |
| setTimeout(function(){ flashHint('RECOVERED'); }, 420); | |
| } | |
| } | |
| } | |
| function separateFromDogs(pos, bodyR){ | |
| for (const d of dogs){ | |
| if (!d.alive) continue; | |
| const dogR = Math.max((d.scale || 0.7) * 0.6, 0.55) + (d.radExtra != null ? d.radExtra : 0.4); | |
| const minSep = bodyR + dogR + 0.08; | |
| let dx = pos[0] - d.pos[0]; | |
| let dz = pos[2] - d.pos[2]; | |
| let dist = Math.hypot(dx, dz); | |
| if (dist < 0.0001) { dx = 1; dz = 0; dist = 1; } | |
| if (dist < minSep) { | |
| const nx = dx / dist, nz = dz / dist; | |
| const need = minSep - dist; | |
| pos[0] += nx * need; | |
| pos[2] += nz * need; | |
| d.pos[0] -= nx * need * 0.25; | |
| d.pos[2] -= nz * need * 0.25; | |
| d.wanderTimer = 0; | |
| sfxBumpThrottled(160); | |
| } | |
| } | |
| } | |
| function updatePatrolHostile(dt){ | |
| if (!patrolHostile.alive) return; | |
| if (tickHostileDeath(patrolHostile, dt, 'PATROL DOWN')) return; | |
| const half = patrolHostile.scale * 0.5 + 0.28; | |
| const target = patrolHostile.targetIsA ? patrolHostile.waypointA : patrolHostile.waypointB; | |
| let dx = target[0] - patrolHostile.pos[0]; | |
| let dz = target[2] - patrolHostile.pos[2]; | |
| const dist = Math.hypot(dx, dz); | |
| if (dist > 0.05) { | |
| const nx = dx / dist, nz = dz / dist; | |
| const step = e31PatrolSpeed() * dt; // [e31] | |
| const tryX = patrolHostile.pos[0] + nx * step; | |
| const tryZ = patrolHostile.pos[2] + nz * step; | |
| const beforeX = patrolHostile.pos[0], beforeZ = patrolHostile.pos[2]; | |
| moveHostileXZ(patrolHostile, tryX, tryZ, half, step); | |
| // if solids blocked most of the step, flip waypoint (keep patrol intent) | |
| if (Math.hypot(patrolHostile.pos[0]-beforeX, patrolHostile.pos[2]-beforeZ) < step * 0.2) { | |
| patrolHostile.targetIsA = !patrolHostile.targetIsA; | |
| } | |
| } else { | |
| patrolHostile.targetIsA = !patrolHostile.targetIsA; | |
| } | |
| const pBeforeX = patrolHostile.pos[0], pBeforeZ = patrolHostile.pos[2]; | |
| separateFromDogs(patrolHostile.pos, patrolHostile.scale * 0.6); | |
| pushOutOfNodes(patrolHostile.pos, half); | |
| if (Math.hypot(patrolHostile.pos[0]-pBeforeX, patrolHostile.pos[2]-pBeforeZ) > 0.15) { | |
| patrolHostile.targetIsA = !patrolHostile.targetIsA; | |
| } | |
| if (patrolHostile.hurtCooldown > 0) patrolHostile.hurtCooldown -= dt; | |
| const d2 = Math.hypot(patrolHostile.pos[0] - cam.pos[0], patrolHostile.pos[2] - cam.pos[2]); | |
| if (cfg.enemyAttack && d2 < 1.2 && hurtCooldown <= 0) { // [e29] toggle | |
| playerHP -= 1; | |
| hurtCooldown = 0.6; | |
| hurtFlash = 0.25; | |
| flashHint('HURT'); sfxHurt(); | |
| if (playerHP <= 0) { | |
| hurtFlash = 0.55; | |
| flashHint('DOWN'); | |
| playerHP = 5; | |
| hurtCooldown = 1.5; | |
| const ax = cam.pos[0] - patrolHostile.pos[0], az = cam.pos[2] - patrolHostile.pos[2]; | |
| const al = Math.hypot(ax, az) || 1; | |
| patrolHostile.pos[0] -= (ax/al) * 2.2; | |
| patrolHostile.pos[2] -= (az/al) * 2.2; | |
| pushOutOfNodes(patrolHostile.pos, half); | |
| setTimeout(function(){ flashHint('RECOVERED'); }, 420); | |
| } | |
| } | |
| } | |
| function updateDogs(dt){ | |
| for (const d of dogs){ | |
| if (!d.alive) continue; | |
| if (d.wanderTimer > 0){ | |
| d.wanderTimer -= dt; | |
| } else { | |
| d.wanderTimer = 2.0 + Math.random()*3.0; | |
| const ang = Math.random()*Math.PI*2; | |
| const rad = 1.2 + Math.random()*2.0; | |
| d.target[0] = d.pos[0] + Math.cos(ang)*rad; | |
| d.target[2] = d.pos[2] + Math.sin(ang)*rad; | |
| } | |
| const dogRad = Math.max((d.scale || 0.7) * 0.55, (d.scale || 0.7) * 0.6) + (d.radExtra != null ? d.radExtra : 0.35); | |
| function solidBoxes(){ | |
| const boxes = []; | |
| function add(minX, maxX, minZ, maxZ){ | |
| boxes.push([minX, maxX, minZ, maxZ]); | |
| } | |
| for (const node of sceneNodes){ | |
| if (!node || !node.pos) continue; | |
| const nm = node.name || ''; | |
| if (nm === 'ground' || nm === 'marker') continue; | |
| if (node.hp !== undefined && node.hp <= 0 && !(node.dying > 0)) continue; | |
| const box = nodeHitAABB(node); | |
| if (!box) continue; | |
| const pad = dogRad + 0.18; | |
| add(box[0][0] - pad, box[1][0] + pad, box[0][2] - pad, box[1][2] + pad); | |
| } | |
| if (!door.open) { | |
| const s = door.scale; | |
| const sx = Array.isArray(s) ? s[0] : s, sz = Array.isArray(s) ? s[2] : s; | |
| const pad = dogRad + 0.1; | |
| add(door.pos[0] - sx*0.5 - pad, door.pos[0] + sx*0.5 + pad, | |
| door.pos[2] - sz*0.5 - pad, door.pos[2] + sz*0.5 + pad); | |
| } | |
| for (const p of ammoPickups){ | |
| if (!p.alive) continue; | |
| const hs = (p.scale || 0.7) * 0.6 + dogRad; | |
| add(p.pos[0] - hs, p.pos[0] + hs, p.pos[2] - hs, p.pos[2] + hs); | |
| } | |
| for (const l of lamps){ | |
| const pr = Math.max(l.postScale[0], l.postScale[2]) * 0.5 + dogRad; | |
| add(l.pos[0] - pr, l.pos[0] + pr, l.pos[2] - pr, l.pos[2] + pr); | |
| } | |
| return boxes; | |
| } | |
| function pushDogOut(pos){ | |
| let hit = false; | |
| const boxes = solidBoxes(); | |
| for (let pass = 0; pass < 5; pass++){ | |
| let moved = false; | |
| for (const b of boxes){ | |
| const minX = b[0], maxX = b[1], minZ = b[2], maxZ = b[3]; | |
| if (pos[0] <= minX || pos[0] >= maxX || pos[2] <= minZ || pos[2] >= maxZ) continue; | |
| const dxL = pos[0] - minX, dxR = maxX - pos[0]; | |
| const dzB = pos[2] - minZ, dzF = maxZ - pos[2]; | |
| const m = Math.min(dxL, dxR, dzB, dzF); | |
| if (m === dxL) pos[0] = minX; | |
| else if (m === dxR) pos[0] = maxX; | |
| else if (m === dzB) pos[2] = minZ; | |
| else pos[2] = maxZ; | |
| moved = true; hit = true; | |
| } | |
| if (!moved) break; | |
| } | |
| return hit; | |
| } | |
| const dx = d.target[0] - d.pos[0]; | |
| const dz = d.target[2] - d.pos[2]; | |
| const dist = Math.hypot(dx, dz); | |
| if (dist > 0.05){ | |
| const nx = dx/dist, nz = dz/dist; | |
| const steps = 3; | |
| const step = (d.speed * dt) / steps; | |
| for (let s = 0; s < steps; s++){ | |
| const ox = d.pos[0], oz = d.pos[2]; | |
| if (levelOn()) { if (!levelMoveChar(d, ox + nx * step, oz + nz * step, 0.35, 0.6, true)) d.wanderTimer = Math.min(d.wanderTimer, 0.15); continue; } // [e30] | |
| d.pos[0] = ox + nx * step; | |
| d.pos[2] = oz + nz * step; | |
| const blocked = pushDogOut(d.pos); | |
| if (Math.hypot(d.pos[0]-ox, d.pos[2]-oz) < step * 0.25) { | |
| d.pos[0] = ox + nx * step; d.pos[2] = oz; | |
| pushDogOut(d.pos); | |
| const xOk = Math.abs(d.pos[0] - ox) > step * 0.2; | |
| if (!xOk) { | |
| d.pos[0] = ox; d.pos[2] = oz + nz * step; | |
| pushDogOut(d.pos); | |
| } | |
| d.wanderTimer = Math.min(d.wanderTimer, 0.15); | |
| } else if (blocked) { | |
| d.wanderTimer = Math.min(d.wanderTimer, 0.4); | |
| } | |
| } | |
| } else { | |
| pushDogOut(d.pos); | |
| } | |
| for (const o of dogs){ | |
| if (o === d || !o.alive) continue; | |
| const ddx = d.pos[0] - o.pos[0]; | |
| const ddz = d.pos[2] - o.pos[2]; | |
| const dd = Math.hypot(ddx, ddz); | |
| const minSep = dogRad + ((o.scale || 0.7) * 0.55 + 0.35) * 0.85; | |
| if (dd > 0.001 && dd < minSep){ | |
| const push = (minSep - dd) * 0.5; | |
| const nnx = ddx / dd, nnz = ddz / dd; | |
| d.pos[0] += nnx * push; | |
| d.pos[2] += nnz * push; | |
| o.pos[0] -= nnx * push; | |
| o.pos[2] -= nnz * push; | |
| pushDogOut(d.pos); | |
| pushDogOut(o.pos); | |
| d.wanderTimer = 0; | |
| o.wanderTimer = 0; | |
| sfxBumpThrottled(220); | |
| } | |
| } | |
| if (!levelOn() && d.pos[0] < -6) d.pos[0] = -6; | |
| if (!levelOn() && d.pos[0] > 6) d.pos[0] = 6; | |
| if (!levelOn() && d.pos[2] < -8) d.pos[2] = -8; | |
| if (!levelOn() && d.pos[2] > 2) d.pos[2] = 2; | |
| } | |
| } | |
| let last = performance.now(); | |
| let frames = 0, fpsTime = 0, fps = 0; | |
| const fpsEl = document.createElement('div'); | |
| fpsEl.style.position='fixed'; | |
| fpsEl.style.right='12px'; | |
| fpsEl.style.top='12px'; | |
| fpsEl.style.color='#9ecbff'; | |
| fpsEl.style.fontSize='12px'; | |
| fpsEl.style.opacity='0.85'; | |
| fpsEl.style.background='rgba(0,0,0,.25)'; | |
| fpsEl.style.padding='6px 10px'; | |
| fpsEl.style.borderRadius='8px'; | |
| fpsEl.style.pointerEvents='none'; | |
| document.body.appendChild(fpsEl); | |
| const crossEl = document.createElement('div'); | |
| crossEl.id = 'muse-cross'; | |
| crossEl.style.cssText = 'position:fixed;left:50%;top:50%;width:18px;height:18px;margin:-9px 0 0 -9px;pointer-events:none;z-index:20;'; | |
| crossEl.innerHTML = '<div style="position:absolute;left:8px;top:0;width:2px;height:18px;background:rgba(220,240,255,.75)"></div><div style="position:absolute;left:0;top:8px;width:18px;height:2px;background:rgba(220,240,255,.75)"></div>'; | |
| document.body.appendChild(crossEl); | |
| const hitMarkEl = document.createElement('div'); | |
| hitMarkEl.id = 'muse-hitmark'; | |
| hitMarkEl.style.cssText = 'position:fixed;left:50%;top:50%;width:28px;height:28px;margin:-14px 0 0 -14px;pointer-events:none;z-index:21;opacity:0;transition:opacity .08s;color:#ffe566;font-size:22px;font-weight:700;text-align:center;line-height:28px;text-shadow:0 0 8px #ffaa00,0 0 2px #000'; | |
| hitMarkEl.textContent = 'X'; | |
| document.body.appendChild(hitMarkEl); | |
| const HIT_STYLES = { | |
| hostile: { mark:'+', css:'#ff4d4d', gl:[1.0,0.25,0.22], flash:[1.0,0.45,0.4], dur:0.34, hint:'HIT HOSTILE' }, | |
| patrol: { mark:'+', css:'#ff9a3c', gl:[1.0,0.55,0.15], flash:[1.0,0.75,0.35], dur:0.34, hint:'HIT PATROL' }, | |
| dog: { mark:'*', css:'#ffe08a', gl:[1.0,0.88,0.45], flash:[1.0,0.9,0.7], dur:0.3, hint:'DOG!' }, | |
| wood: { mark:'#', css:'#c48a4a', gl:[0.85,0.55,0.28], flash:[0.95,0.7,0.4], dur:0.28, hint:'WOOD HIT' }, | |
| metal: { mark:'+', css:'#d8f0ff', gl:[0.75,0.9,1.0], flash:[0.85,0.95,1.0], dur:0.26, hint:'METAL HIT' }, | |
| wall: { mark:'x', css:'#a8b4c4', gl:[0.7,0.78,0.88], flash:[0.85,0.88,0.92], dur:0.26, hint:'WALL HIT' }, | |
| stone: { mark:'*', css:'#d2c4a0', gl:[0.85,0.78,0.55], flash:[0.9,0.85,0.7], dur:0.3, hint:'STONE HIT' }, | |
| soft: { mark:'X', css:'#ffe566', gl:[1.0,0.92,0.25], flash:[1.0,0.75,0.45], dur:0.28, hint:'TARGET HIT' }, | |
| lamp: { mark:'*', css:'#fff6b0', gl:[1.0,0.95,0.55], flash:[1.0,1.0,0.85], dur:0.45, hint:'LAMP HIT', burst:true } | |
| }; | |
| function resolveHitStyle(kind, node){ | |
| if (kind === 'hostile') return HIT_STYLES.hostile; | |
| if (kind === 'patrol') return HIT_STYLES.patrol; | |
| if (kind === 'dog') return HIT_STYLES.dog; | |
| if (kind === 'lamp') return HIT_STYLES.lamp; | |
| const name = (node && node.name) ? String(node.name) : ''; | |
| if (/barrel|door/i.test(name)) return HIT_STYLES.metal; | |
| if (/bayWall|warehouse|shed|pier/i.test(name)) return HIT_STYLES.wall; | |
| if (/cover|crate|rope|Stack/i.test(name)) return HIT_STYLES.wood; | |
| if (/glb|glbImport/i.test(name)) return HIT_STYLES.stone; | |
| if (/Pyramid|Rock|obj/i.test(name)) return HIT_STYLES.stone; | |
| if (kind === 'impact') { | |
| if (/Wall|warehouse|shed/i.test(name)) return HIT_STYLES.wall; | |
| if (/cover|rope/i.test(name)) return HIT_STYLES.wood; | |
| return HIT_STYLES.wall; | |
| } | |
| return HIT_STYLES.soft; | |
| } | |
| function showHitMarker(style){ | |
| if (!cfg.fx) return; // [e29] engine part: FX | |
| style = style || HIT_STYLES.soft; | |
| hitMarkEl.textContent = style.mark; | |
| hitMarkEl.style.color = style.css; | |
| hitMarkEl.style.textShadow = '0 0 10px ' + style.css + ',0 0 2px #000'; | |
| hitMarkEl.style.opacity = '1'; | |
| hitMarkEl.style.fontSize = style.burst ? '36px' : '22px'; | |
| hitMarkEl.style.width = style.burst ? '48px' : '28px'; | |
| hitMarkEl.style.height = style.burst ? '48px' : '28px'; | |
| hitMarkEl.style.margin = style.burst ? '-24px 0 0 -24px' : '-14px 0 0 -14px'; | |
| hitMarkEl.style.lineHeight = style.burst ? '48px' : '28px'; | |
| hitMarkEl.style.transform = style.burst ? 'scale(1.6)' : 'scale(1.2)'; | |
| if (style.burst) { | |
| hitMarkEl.style.border = '2px solid ' + style.css; | |
| hitMarkEl.style.borderRadius = '50%'; | |
| hitMarkEl.style.background = 'rgba(255,240,160,0.25)'; | |
| hitMarkEl.style.boxShadow = '0 0 18px ' + style.css; | |
| } else { | |
| hitMarkEl.style.border = 'none'; | |
| hitMarkEl.style.borderRadius = '0'; | |
| hitMarkEl.style.background = 'transparent'; | |
| hitMarkEl.style.boxShadow = 'none'; | |
| } | |
| hitStyleGl = style.gl.slice(); | |
| hitStyleDur = style.dur; | |
| hitFlashTimer = style.dur; | |
| setTimeout(function(){ | |
| hitMarkEl.style.opacity = '0'; | |
| hitMarkEl.style.transform = 'scale(1)'; | |
| hitMarkEl.style.border = 'none'; | |
| hitMarkEl.style.background = 'transparent'; | |
| hitMarkEl.style.boxShadow = 'none'; | |
| }, Math.max(180, style.dur * 1000 * 0.75)); | |
| } | |
| function applyObjectFlash(node, style){ | |
| if (!node || !style) return; | |
| node.hitFlash = Math.max(node.hitFlash || 0, style.dur * 0.65); | |
| node.hitFlashColor = style.flash.slice(); | |
| } | |
| function coachDump(){ | |
| try { | |
| const stamp = new Date().toISOString().replace(/[:.]/g,'-'); | |
| const state = { | |
| stamp, | |
| title: document.title, | |
| cam: { pos: cam.pos.slice(), yaw: cam.yaw, pitch: cam.pitch }, | |
| ammoMag: ammoMag, | |
| ammoReserve: ammoReserve, | |
| reloading: reloading, | |
| nightMode: nightMode, | |
| score: score, | |
| hostile: { alive: hostile.alive, hp: hostile.hp, pos: hostile.pos.slice(), dying: hostile.dying }, | |
| patrol: { alive: patrolHostile.alive, hp: patrolHostile.hp, pos: patrolHostile.pos.slice(), dying: patrolHostile.dying }, | |
| dogs: dogs.map(d=>({pos:d.pos.slice(), alive:d.alive})), | |
| targets: targets.map(t => ({ name: t.name, hp: t.hp, dying: t.dying||0, pos: t.pos ? t.pos.slice() : null })), | |
| lastHint: lastHintText | |
| }; | |
| const a1 = document.createElement('a'); | |
| a1.href = URL.createObjectURL(new Blob([JSON.stringify(state,null,2)], {type:'application/json'})); | |
| a1.download = 'muse-coach-'+stamp+'.json'; | |
| document.body.appendChild(a1); a1.click(); a1.remove(); | |
| setTimeout(function(){ URL.revokeObjectURL(a1.href); }, 2000); | |
| const a2 = document.createElement('a'); | |
| a2.href = canvas.toDataURL('image/png'); | |
| a2.download = 'muse-coach-'+stamp+'.png'; | |
| document.body.appendChild(a2); a2.click(); a2.remove(); | |
| flashHint('COACH DUMP'); | |
| } catch (e) { | |
| console.warn(e); | |
| flashHint('DUMP FAIL'); | |
| } | |
| } | |
| function showCredit(duration=2500){ | |
| if (!creditEl) return; | |
| creditEl.style.opacity='1'; | |
| creditEl.style.pointerEvents='auto'; | |
| setTimeout(()=>{ | |
| creditEl.style.opacity='0'; | |
| creditEl.style.pointerEvents='none'; | |
| }, duration); | |
| } | |
| // ===================================================================== | |
| // [e29 coach] Look picker + config panel hooks. | |
| // Visual swap only: colliders, hitboxes, AI and timings are untouched by | |
| // looks. Gameplay toggles (config panel) are opt-in and default to the | |
| // shipped Phase 29 behaviour. | |
| // ===================================================================== | |
| const cylMesh = (function(seg){ | |
| const P = [], N = []; | |
| for (let i = 0; i < seg; i++){ | |
| const a0 = i / seg * Math.PI * 2, a1 = (i + 1) / seg * Math.PI * 2; | |
| const c0 = Math.cos(a0), s0 = Math.sin(a0), c1 = Math.cos(a1), s1 = Math.sin(a1); | |
| const x0 = c0*0.5, z0 = s0*0.5, x1 = c1*0.5, z1 = s1*0.5; | |
| P.push(x0,-0.5,z0, x1,0.5,z1, x1,-0.5,z1, x0,-0.5,z0, x0,0.5,z0, x1,0.5,z1); | |
| N.push(c0,0,s0, c1,0,s1, c1,0,s1, c0,0,s0, c0,0,s0, c1,0,s1); | |
| P.push(0,0.5,0, x1,0.5,z1, x0,0.5,z0); N.push(0,1,0, 0,1,0, 0,1,0); | |
| P.push(0,-0.5,0, x0,-0.5,z0, x1,-0.5,z1); N.push(0,-1,0, 0,-1,0, 0,-1,0); | |
| } | |
| return createMesh(new Float32Array(P), new Float32Array(N)); | |
| })(20); | |
| function builtinGroundPixel(x, y){ | |
| // exact Phase 29 checker (moved into a function so looks can repaint) | |
| const cx = Math.floor(x/tile)%2; | |
| const cy = Math.floor(y/tile)%2; | |
| const edge = ((x%tile)<2 || (y%tile)<2) ? 1 : 0; | |
| const v = (cx ^ cy) ? 210 : 95; | |
| return [v + edge*25, (v*0.85) + edge*18, (v*0.55) + edge*10]; | |
| } | |
| // Look A built-ins = the exact Phase 29 values (captured before any look applies) | |
| const LOOK_A_BUILTIN = { | |
| id: 'A', name: 'Look A · Breaker Bay (Phase 29)', | |
| env: { | |
| dayClear:[0.45,0.58,0.78], dayFog:[0.45,0.58,0.78], | |
| nightClearBase:[0.005,0.007,0.020], nightClearPerLamp:[0.004,0.005,0.008], | |
| nightFogBase:[0.01,0.015,0.04], nightFogPerLamp:[0.004,0.005,0.01], | |
| fogNear:8.0, fogFar:28.0, | |
| sunDirDay:[0.5,0.8,0.6], sunDirNight:[0.25,0.95,0.35], | |
| dayAmbient:0.25, dayDiffuse:0.75, | |
| nightAmbientBase:0.022, nightAmbientPerLamp:0.008, | |
| nightDiffuseBase:0.045, nightDiffusePerLamp:0.015, | |
| lampColor1:[1.0,0.88,0.48], lampColor2:[0.95,0.82,0.42], lampBoost:2.75, | |
| gridColor:[0.75,0.85,1.0] | |
| }, | |
| groundPixel: null, | |
| nodes: {}, | |
| trims: {}, | |
| lamps: { post:[0.35,0.35,0.38], bulbOn:[1.0,0.92,0.45], bulbOff:[0.22,0.22,0.24], bulbBroken:[0.12,0.12,0.13] }, | |
| ammo: { body:[0.42,0.28,0.16], lid:[0.48,0.32,0.18] }, | |
| charBox: { | |
| hostile: { color:[0.9,0.2,0.2], spec:[0.6,0.2,0.2], shin:40 }, | |
| patrol: { color:[0.95,0.65,0.15], spec:[0.7,0.4,0.1], shin:40 }, | |
| dog: { color:dogs[0].color.slice(), spec:dogs[0].spec.slice(), shin:dogs[0].shin } | |
| }, | |
| characters: { hostile:null, patrol:null, dog:null }, | |
| backdrop: [] | |
| }; | |
| for (const n of sceneNodes) LOOK_A_BUILTIN.nodes[n.name] = { color:n.color.slice(), spec:(n.spec||[0.2,0.2,0.2]).slice(), shin:(n.shin||32) }; | |
| LOOK_A_BUILTIN.nodes.door = { color:door.color.slice(), spec:door.spec.slice(), shin:door.shin }; | |
| { const b = lookProp.authoredParts[0]; LOOK_A_BUILTIN.nodes.bollard = { color:b.color.slice(), spec:b.spec.slice(), shin:b.shin }; } | |
| const lookIssues = []; | |
| function _fin(x){ return typeof x === 'number' && isFinite(x); } | |
| function _col(a, d){ return (Array.isArray(a) && a.length === 3 && a.every(_fin)) ? a.map(x => Math.max(0, Math.min(1, x))) : (d ? d.slice() : null); } | |
| function _vec(a, d){ return (Array.isArray(a) && a.length === 3 && a.every(_fin) && (a[0]||a[1]||a[2])) ? a.slice() : d.slice(); } | |
| function _num(a, d, lo, hi){ return _fin(a) ? Math.max(lo, Math.min(hi, a)) : d; } | |
| function _partMatrix(p){ | |
| const m = identity(); | |
| m[0] = p.size[0]; m[5] = p.size[1]; m[10] = p.size[2]; | |
| m[12] = p.pos[0]; m[13] = p.pos[1]; m[14] = p.pos[2]; | |
| return m; | |
| } | |
| function _parts(list, max, where, opts){ | |
| const out = []; | |
| if (!Array.isArray(list)) return out; | |
| if (list.length > max) lookIssues.push(where + ': ' + list.length + ' parts > max ' + max + ' (extra dropped)'); | |
| for (const p of list.slice(0, max)){ | |
| if (!p || !Array.isArray(p.pos) || p.pos.length !== 3 || !p.pos.every(_fin) || | |
| !Array.isArray(p.size) || p.size.length !== 3 || !p.size.every(_fin)) { lookIssues.push(where + ': malformed part dropped'); continue; } | |
| const q = { | |
| shape: p.shape === 'cyl' ? 'cyl' : 'box', | |
| pos: p.pos.slice(), | |
| size: p.size.map(s => Math.max(0.005, Math.min(opts.maxSize, Math.abs(s)))), | |
| color: _col(p.color, [0.6,0.6,0.6]), | |
| spec: _col(p.spec, [0.2,0.2,0.2]), | |
| shin: _num(p.shin, 24, 1, 256) | |
| }; | |
| if (opts.backdrop && Math.abs(q.pos[0]) <= 11 && Math.abs(q.pos[2]) <= 11) { lookIssues.push(where + ': part inside play area dropped'); continue; } | |
| q.lm = _partMatrix(q); | |
| out.push(q); | |
| } | |
| return out; | |
| } | |
| function resolveLook(pack){ | |
| lookIssues.length = 0; | |
| const A = LOOK_A_BUILTIN; | |
| const src = pack || A; | |
| const E = src.env || {}; | |
| const D = A.env; | |
| const L = { id: String(src.id || 'A'), name: String(src.name || src.id || 'Look'), env: {} }; | |
| for (const k of ['dayClear','nightClearBase','nightClearPerLamp','nightFogBase','nightFogPerLamp','lampColor1','lampColor2']) L.env[k] = _col(E[k], D[k]); | |
| L.env.dayFog = _col(E.dayFog, L.env.dayClear); | |
| L.env.sunDirDay = _vec(E.sunDirDay, D.sunDirDay); | |
| L.env.sunDirNight = _vec(E.sunDirNight, D.sunDirNight); | |
| L.env.fogNear = _num(E.fogNear, D.fogNear, 0, 200); | |
| L.env.fogFar = _num(E.fogFar, D.fogFar, L.env.fogNear + 1, 400); | |
| for (const k of ['dayAmbient','dayDiffuse','nightAmbientBase','nightAmbientPerLamp','nightDiffuseBase','nightDiffusePerLamp']) L.env[k] = _num(E[k], D[k], 0, 2); | |
| L.env.lampBoost = _num(E.lampBoost, D.lampBoost, 0, 8); | |
| L.env.gridColor = (E.gridColor === null) ? null : _col(E.gridColor, D.gridColor); | |
| L.groundPixel = (typeof src.groundPixel === 'function') ? src.groundPixel : null; | |
| L.nodes = {}; | |
| const SN = src.nodes || {}; | |
| for (const name in A.nodes){ | |
| const d = A.nodes[name], n = SN[name] || {}; | |
| L.nodes[name] = { color:_col(n.color, d.color), spec:_col(n.spec, d.spec), shin:_num(n.shin, d.shin, 1, 256) }; | |
| } | |
| L.trims = {}; | |
| const ST = src.trims || {}; | |
| for (const name in ST){ | |
| if (!A.nodes[name] || name === 'ground' || name === 'marker') { lookIssues.push('trims.' + name + ': unknown/unsupported node ignored'); continue; } | |
| L.trims[name] = _parts(ST[name], 8, 'trims.' + name, { maxSize: 1.2 }); | |
| } | |
| const SLm = src.lamps || {}; | |
| L.lamps = {}; for (const k in A.lamps) L.lamps[k] = _col(SLm[k], A.lamps[k]); | |
| { // [e29 lampfix] a shot bulb must read as OUT: never let bulbBroken be brighter than bulbOff | |
| const lum = c => 0.2126*c[0] + 0.7152*c[1] + 0.0722*c[2]; | |
| const lb = lum(L.lamps.bulbBroken), lo = lum(L.lamps.bulbOff); | |
| if (lb > lo && lb > 0) { | |
| const f = (0.5 * lo) / lb; | |
| L.lamps.bulbBroken = L.lamps.bulbBroken.map(v => v * f); | |
| lookIssues.push('lamps.bulbBroken brighter than bulbOff: darkened so shot lamps read as out'); | |
| } | |
| } | |
| const SA = src.ammo || {}; | |
| L.ammo = { body:_col(SA.body, A.ammo.body), lid:_col(SA.lid, A.ammo.lid) }; | |
| L.charBox = {}; | |
| L.characters = {}; | |
| const SC = src.characters || {}; | |
| for (const k of ['hostile','patrol','dog']){ | |
| const c = SC[k] || {}; | |
| const d = A.charBox[k]; | |
| L.charBox[k] = { color:_col(c.color, d.color), spec:_col(c.spec, d.spec), shin:_num(c.shin, d.shin, 1, 256) }; | |
| const parts = _parts(c.parts, 12, 'characters.' + k, { maxSize: 1.2 }); | |
| L.characters[k] = parts.length ? parts : null; | |
| } | |
| L.backdrop = _parts(src.backdrop, 30, 'backdrop', { maxSize: 30, backdrop: true }); | |
| if (lookIssues.length) console.info('[look ' + L.id + '] sanitised:', lookIssues.slice()); | |
| L.issues = lookIssues.slice(); | |
| return L; | |
| } | |
| function paintGround(fn){ | |
| const data = imgData.data; | |
| let failed = false; | |
| for (let y = 0; y < sizeTex; y++){ | |
| for (let x = 0; x < sizeTex; x++){ | |
| let c = null; | |
| if (fn && !failed) { | |
| try { c = fn(x, y, sizeTex); } catch (err) { failed = true; console.info('[look] groundPixel threw, using built-in checker', err && err.message); } | |
| if (!(Array.isArray(c) && c.length >= 3 && _fin(c[0]) && _fin(c[1]) && _fin(c[2]))) c = null; | |
| } | |
| if (!c) c = builtinGroundPixel(x, y); | |
| const i = (y*sizeTex + x)*4; | |
| data[i] = Math.max(0, Math.min(255, c[0]|0)); | |
| data[i+1] = Math.max(0, Math.min(255, c[1]|0)); | |
| data[i+2] = Math.max(0, Math.min(255, c[2]|0)); | |
| data[i+3] = 255; | |
| } | |
| } | |
| ctx.putImageData(imgData, 0, 0); | |
| gl.bindTexture(gl.TEXTURE_2D, albedoTex); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvasTex); | |
| } | |
| let curLook = resolveLook(null); | |
| function resetLamps(){ // [e29 lampfix] fresh lamps on look switch: no dead lamps carried across looks | |
| for (const l of lamps) { l.on = true; l.broken = false; l.hitFlash = 0; l.hitFlashColor = null; } | |
| } | |
| function applyLook(pack){ | |
| const prevLookId = curLook ? curLook.id : null; | |
| curLook = resolveLook(pack); | |
| if (prevLookId !== curLook.id) resetLamps(); | |
| const allNodes = sceneNodes.concat(parkourStash.nodes); | |
| for (const node of allNodes){ | |
| const n = curLook.nodes[node.name]; | |
| if (!n) continue; | |
| node.color = n.color.slice(); node.spec = n.spec.slice(); node.shin = n.shin; | |
| } | |
| const dn = curLook.nodes.door; door.color = dn.color.slice(); door.spec = dn.spec.slice(); door.shin = dn.shin; | |
| const bn = curLook.nodes.bollard, bp = lookProp.authoredParts[0]; | |
| bp.color = bn.color.slice(); bp.spec = bn.spec.slice(); bp.shin = bn.shin; | |
| for (const p of ammoPickups) p.color = curLook.ammo.body.slice(); | |
| for (const d of dogs) { d.color = curLook.charBox.dog.color.slice(); d.spec = curLook.charBox.dog.spec.slice(); d.shin = curLook.charBox.dog.shin; } | |
| paintGround(curLook.groundPixel); | |
| return curLook; | |
| } | |
| function lookFromRegistry(){ | |
| const M = window.MuseLooks; | |
| if (!M || typeof M.get !== 'function') return null; | |
| try { return M.get(M.active()) || null; } catch (err) { return null; } | |
| } | |
| function cycleLook(){ | |
| if (window.MuseLooks && typeof window.MuseLooks.next === 'function') window.MuseLooks.next(); | |
| else flashHint('LOOK A only (no look packs loaded)'); | |
| } | |
| // ---- imported models (config panel: scene / character) ---- | |
| const importSlots = { scene: null, character: null }; | |
| function _potCanvas(img){ | |
| const w = img.width || img.videoWidth || 1, h = img.height || img.videoHeight || 1; | |
| const pw = Math.min(1024, Math.pow(2, Math.round(Math.log2(Math.max(1, w))))); | |
| const ph = Math.min(1024, Math.pow(2, Math.round(Math.log2(Math.max(1, h))))); | |
| const c = document.createElement('canvas'); c.width = pw; c.height = ph; | |
| c.getContext('2d').drawImage(img, 0, 0, pw, ph); | |
| return c; | |
| } | |
| function _freeSlot(slot){ | |
| const s = importSlots[slot]; | |
| if (!s) return; | |
| for (const p of s.parts){ try { gl.deleteBuffer(p.mesh.vbo); } catch(_){} if (p.tex && !p.sharedTex) { try { gl.deleteTexture(p.tex); } catch(_){} } } // [e32] shared textures freed once | |
| importSlots[slot] = null; | |
| } | |
| function setImportedModel(slot, meshes, info){ | |
| if (slot !== 'scene' && slot !== 'character') throw new Error('slot must be scene|character'); | |
| _freeSlot(slot); | |
| if (!meshes || !meshes.length) return null; | |
| const parts = []; | |
| const _texCache = new Map(); // [e32] image -> GL texture (parts that share a material share one texture) | |
| for (const m of meshes){ | |
| const mesh = createMesh(m.positions, m.normals, m.uvs || null); | |
| const part = { mesh: mesh, color: _col(m.color, [0.8,0.8,0.8]), spec: [0.25,0.25,0.25], shin: 32, useTex: false, useNm: false, local: null, tex: null }; | |
| { const P = m.positions; let x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity, z1 = -Infinity; // [e32] bounding sphere | |
| for (let i = 0; i < P.length; i += 3) { const x = P[i], y = P[i+1], z = P[i+2]; if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; if (z < z0) z0 = z; if (z > z1) z1 = z; } | |
| part.bs = P.length ? [(x0+x1)/2, (y0+y1)/2, (z0+z1)/2, Math.hypot(x1-x0, y1-y0, z1-z0) / 2] : null; } | |
| const _tk = m.image ? (_texCache.get(m.image) || {})[m.flipY ? 1 : 0] : null; | |
| if (_tk) { part.tex = _tk; part.useTex = true; part.sharedTex = true; } | |
| else if (m.image) { | |
| try { | |
| const tex = gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D, tex); | |
| gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, !!m.flipY); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, _potCanvas(m.image)); | |
| gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); | |
| gl.generateMipmap(gl.TEXTURE_2D); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); | |
| part.tex = tex; part.useTex = true; | |
| { const e = _texCache.get(m.image) || {}; e[m.flipY ? 1 : 0] = tex; _texCache.set(m.image, e); } // [e32] | |
| } catch (err) { console.info('[import] texture skipped', err && err.message); } | |
| } | |
| parts.push(part); | |
| } | |
| importSlots[slot] = { parts: parts, info: info || {} }; | |
| flashHint((slot === 'scene' ? 'SCENE MODEL ' : 'CHARACTER MODEL ') + ((info && info.name) || '')); | |
| return importSlots[slot].info; | |
| } | |
| function clearImportedModel(slot){ _freeSlot(slot); flashHint(slot === 'scene' ? 'SCENE: BUILT-IN' : 'CHARACTERS: BUILT-IN'); } | |
| // ---- config (gameplay toggles) ---- | |
| const CFG_KEY = 'muse-engine-29.settings'; | |
| const ZERO3 = [0, 0, 0]; | |
| // Every default reproduces the original Phase 29 behaviour exactly. | |
| const CFG_DEFAULTS = { | |
| // engine parts | |
| shadows: true, textures: true, lightExtras: true, fx: true, fog: true, | |
| collision: true, enemyAI: true, gravity: true, sound: true, | |
| // gameplay / scene | |
| enemies: true, enemyAttack: true, dogs: true, | |
| playerShoot: true, infiniteAmmo: false, | |
| jump: true, parkour: true, | |
| deathFx: true, hud: true, grid: true, bollard: true, night: false, | |
| builtinProps: true, importDog: false, | |
| stripRootMotion: true // [e32] play clips in place (root motion removed) | |
| }; | |
| const cfg = Object.assign({}, CFG_DEFAULTS); | |
| try { const raw = localStorage.getItem(CFG_KEY); if (raw) { const o = JSON.parse(raw); for (const k in CFG_DEFAULTS) if (typeof o[k] === 'boolean') cfg[k] = o[k]; } } catch (_) {} | |
| const shadowCapable = useShadowMap; | |
| const parkourStash = { nodes: [], blocks: [] }; | |
| const PARKOUR_NAMES = /^(parkour\d|ladder|ladderDeck)$/; | |
| function _applyParkour(on){ | |
| if (level && level.active) return; // [e30] built-in parkour is stashed while a scene is the level | |
| if (!on && !parkourStash.nodes.length) { | |
| for (let i = sceneNodes.length - 1; i >= 0; i--) if (PARKOUR_NAMES.test(sceneNodes[i].name)) parkourStash.nodes.unshift(sceneNodes.splice(i, 1)[0]); | |
| parkourStash.blocks = parkourBlocks.splice(0, parkourBlocks.length); | |
| onLadder = false; | |
| } else if (on && parkourStash.nodes.length) { | |
| for (const n of parkourStash.nodes) sceneNodes.push(n); | |
| for (const b of parkourStash.blocks) parkourBlocks.push(b); | |
| parkourStash.nodes = []; parkourStash.blocks = []; | |
| } | |
| } | |
| const hudEls = function(){ return [fpsEl, document.getElementById('hud'), document.getElementById('banner'), hintEl]; }; | |
| function applyConfig(){ | |
| _applyParkour(cfg.parkour); | |
| useShadowMap = cfg.shadows && shadowCapable; | |
| lookProp.visible = cfg.bollard; | |
| nightMode = cfg.night; | |
| if (cfg.infiniteAmmo && ammoMag <= 0) ammoMag = MAG_CAP; | |
| for (const el of hudEls()) if (el) el.style.visibility = cfg.hud ? '' : 'hidden'; | |
| } | |
| function setConfig(partial, silent){ | |
| for (const k in partial) if (k in CFG_DEFAULTS && typeof partial[k] === 'boolean') cfg[k] = partial[k]; | |
| applyConfig(); | |
| try { localStorage.setItem(CFG_KEY, JSON.stringify(cfg)); } catch (_) {} | |
| return Object.assign({}, cfg); | |
| } | |
| // ---- pause / input block while an overlay (start screen, config panel) is open ---- | |
| let uiBlocked = false; | |
| function setBlocked(on){ | |
| uiBlocked = !!on; | |
| if (uiBlocked) { | |
| for (const k in keys) keys[k] = false; | |
| mouseDown = false; pendingShoot = false; | |
| stopDemoDrive(); | |
| if (document.pointerLockElement) document.exitPointerLock?.(); | |
| } | |
| } | |
| // ---- draw helpers for look parts / imported models ---- | |
| const FLASH_SPEC = [0.9,0.8,0.5]; | |
| function drawLookParts(parts, parentM, flashCol, depth){ | |
| for (let i = 0; i < parts.length; i++){ | |
| const p = parts[i]; | |
| const wm = multiply(p.lm, parentM); // engine multiply(a,b) == B·A → parent · local | |
| const mesh = p.shape === 'cyl' ? cylMesh : crateMesh; | |
| if (depth) { drawMeshDepth(mesh, wm); continue; } | |
| gl.uniform1f(uUseTex, 0.0); | |
| drawMesh(mesh, wm, flashCol || p.color, flashCol ? FLASH_SPEC : p.spec, flashCol ? 40 : p.shin); | |
| } | |
| } | |
| function drawImportParts(slot, parentM, flashCol, depth){ | |
| const s = importSlots[slot]; if (!s) return; | |
| for (const part of s.parts){ | |
| if (depth) { drawMeshDepth(part.mesh, parentM); continue; } | |
| if (slot === 'scene' && parentM === IDENT && !e32Visible(part)) continue; // [e32] frustum culling (batched level parts) | |
| if (flashCol) drawTexturedPart({ mesh: part.mesh, color: flashCol, spec: FLASH_SPEC, shin: 40, useTex: false, local: null }, parentM); | |
| else drawTexturedPart(part, parentM); | |
| } | |
| if (!depth) gl.uniform1f(uUseTex, 0.0); | |
| } | |
| // kind: hostile|patrol|dog. m = the existing collider-sized body matrix. | |
| function drawCharacter(kind, m, mesh, color, spec, shin, flashing, depth){ | |
| if (e31Draw(kind, flashing ? color : null, depth)) return; // [e31] animated rig | |
| if (importSlots.character && !importSlots.character.rig && (kind !== 'dog' || cfg.importDog)) { drawImportParts('character', m, flashing ? color : null, depth); return; } | |
| const parts = curLook.characters[kind]; | |
| if (parts) { drawLookParts(parts, m, flashing ? color : null, depth); return; } | |
| if (depth) { drawMeshDepth(mesh, m); return; } | |
| gl.uniformMatrix4fv(uModel, false, m); | |
| drawMesh(mesh, m, color, spec, shin); | |
| } | |
| function drawNodeTrims(node, model, flashCol, depth){ | |
| const t = curLook.trims[node.name]; | |
| if (t && t.length) drawLookParts(t, model, flashCol, depth); | |
| } | |
| function hideBuiltinProp(node){ | |
| return !cfg.builtinProps && !!importSlots.scene && node.name !== 'ground' && node.name !== 'marker'; | |
| } | |
| const IDENT = identity(); | |
| function drawLookWorldExtras(depth){ | |
| if (curLook.backdrop.length) drawLookParts(curLook.backdrop, IDENT, null, depth); | |
| if (importSlots.scene) drawImportParts('scene', IDENT, null, depth); | |
| } | |
| // ---- public API for looks/, ui/ scripts and the local test harness ---- | |
| window.MuseEngine29 = { | |
| version: 'e29-lookpicker-1', | |
| applyLook: function(){ return applyLook(lookFromRegistry()); }, | |
| look: function(){ return { id: curLook.id, name: curLook.name, issues: curLook.issues.slice() }; }, | |
| builtinLookA: function(){ return JSON.parse(JSON.stringify(LOOK_A_BUILTIN)); }, | |
| groundPixelBuiltin: function(x, y){ return builtinGroundPixel(x, y); }, | |
| cycleLook: cycleLook, | |
| getConfig: function(){ return Object.assign({}, cfg); }, | |
| configDefaults: function(){ return Object.assign({}, CFG_DEFAULTS); }, | |
| setConfig: setConfig, | |
| setBlocked: setBlocked, | |
| isBlocked: function(){ return uiBlocked; }, | |
| setImportedModel: setImportedModel, | |
| clearImportedModel: clearImportedModel, | |
| importInfo: function(slot){ return importSlots[slot] ? Object.assign({}, importSlots[slot].info) : null; }, | |
| flashHint: function(t){ flashHint(t); }, | |
| status: function(){ | |
| return { | |
| look: curLook.id, fps: fps, paused: uiBlocked, night: nightMode, shadows: useShadowMap, | |
| hp: playerHP, ammoMag: ammoMag, ammoReserve: ammoReserve, score: score, airY: airY, | |
| cam: [cam.pos[0], cam.pos[1], cam.pos[2]], | |
| hostile: { alive: hostile.alive, hp: hostile.hp, dying: hostile.dying, pos: hostile.pos.slice() }, | |
| patrol: { alive: patrolHostile.alive, hp: patrolHostile.hp, dying: patrolHostile.dying, pos: patrolHostile.pos.slice() }, | |
| dogs: dogs.map(d => ({ alive: d.alive, pos: d.pos.slice() })), | |
| sceneNodes: sceneNodes.length, parkourBlocks: parkourBlocks.length, lastHint: lastHintText, | |
| lookProp: { visible: !!lookProp.visible, parts: (lookProp.parts || []).length }, // [lookB-fix] | |
| lamps: lamps.map(l => ({ name: l.name, on: l.on, broken: l.broken, lit: lampLit(l) })), // [e29 lampfix] | |
| imports: { scene: importSlots.scene ? importSlots.scene.info : null, character: importSlots.character ? importSlots.character.info : null } | |
| }; | |
| } | |
| }; | |
| if (/[?&]test=1/.test(location.search)) { | |
| // local test harness only (not used by the UI) | |
| window.MuseEngine29.test = { | |
| place: function(x, z, yaw, pitch){ cam.pos[0] = x; cam.pos[2] = z; if (yaw != null) cam.yaw = yaw; if (pitch != null) cam.pitch = pitch; }, | |
| aimAt: function(which){ | |
| const h = which === 'patrol' ? patrolHostile : hostile; | |
| const tx = h.pos[0], tz = h.pos[2], ty = (h.baseY || 0) + h.scale * 0.5; | |
| const dx = tx - cam.pos[0], dz = tz - cam.pos[2], dy = ty - cam.pos[1]; | |
| cam.yaw = Math.atan2(dx, -dz); cam.pitch = Math.atan2(dy, Math.hypot(dx, dz)); | |
| return [cam.yaw, cam.pitch]; | |
| }, | |
| shoot: function(){ shootCooldown = 0; tryShoot(); return { ammoMag: ammoMag, score: score }; }, | |
| jump: function(){ keys['Space'] = true; jumpBufferTimer = JUMP_BUFFER; setTimeout(function(){ keys['Space'] = false; }, 80); }, | |
| aimAtLamp: function(i){ // [e29 lampfix] aim at the centre of lamp i's bulb | |
| const t = lampBulbTip(lamps[i]); | |
| const dx = t[0] - cam.pos[0], dz = t[2] - cam.pos[2], dy = t[1] - cam.pos[1]; | |
| cam.yaw = Math.atan2(dx, -dz); cam.pitch = Math.atan2(dy, Math.hypot(dx, dz)); | |
| return [cam.yaw, cam.pitch]; | |
| }, | |
| lampColors: function(){ return JSON.parse(JSON.stringify(curLook.lamps)); }, | |
| freezeHostiles: function(on){ hostile.speed = on ? 0 : 1.35; patrolHostile.speed = on ? 0 : 1.8; } | |
| }; | |
| } | |
| applyConfig(); | |
| { | |
| const qm = location.search.match(/[?&]look=([A-Za-z0-9_-]+)/); | |
| if (qm && window.MuseLooks && typeof window.MuseLooks.get === 'function' && window.MuseLooks.get(qm[1])) { | |
| try { window.MuseLooks.setActive(qm[1]); } catch (_) {} | |
| } | |
| } | |
| applyLook(lookFromRegistry()); | |
| if (window.MuseLooks && typeof window.MuseLooks.onChange === 'function') { | |
| window.MuseLooks.onChange(function(){ | |
| applyLook(lookFromRegistry()); | |
| flashHint('LOOK ' + curLook.id + ' · ' + curLook.name); | |
| }); | |
| } | |
| // ===================== [e29 coach] end of look/config block ===================== | |
| // ===================== [e30 coach] Phase 30: loaded scene = the level (mesh colliders), crosshair fix, pyramid hit ===================== | |
| // The collider maths (grid, ground/wall/ray queries, spawn search) is Muse-authored with coach fixes: engine/physics/mesh-collider.js | |
| // (see game/muse-authored/phase30/FIXES-P30.md). The footprint probe below is coach code. | |
| // This block wires it into the Phase 28/29 movement, AI, shooting and draw code. Built-in colliders come back when the | |
| // "Scene colliders" toggle is off or the scene is cleared. | |
| CFG_DEFAULTS.sceneColliders = true; | |
| try { const o30 = JSON.parse(localStorage.getItem(CFG_KEY) || '{}'); cfg.sceneColliders = (typeof o30.sceneColliders === 'boolean') ? o30.sceneColliders : true; } catch (_) { cfg.sceneColliders = true; } | |
| var level = { collider: null, spawnNode: null, spawn: null, active: false, stash: null, stats: null, respawns: 0, lastShot: null, buildMs: 0 }; | |
| const LV_H = 1.75, BUILTIN_SPAWN = [0, 1.6, 5]; | |
| function levelOn(){ return !!(level && level.active); } | |
| function _lvCell(stats){ const sx = stats.max[0] - stats.min[0], sz = stats.max[2] - stats.min[2]; return Math.max(1.0, Math.min(8.0, Math.sqrt(Math.max(1, sx * sz) / Math.max(1, stats.tris / 12)))); } | |
| function buildLevelCollider(positions){ | |
| const MC = window.MuseMeshCollider; | |
| if (!MC || !MC.build || !positions || positions.length < 9) return null; | |
| const t0 = performance.now(); | |
| let mn = [Infinity, Infinity, Infinity], mx = [-Infinity, -Infinity, -Infinity]; | |
| for (let i = 0; i < positions.length; i += 3) for (let k = 0; k < 3; k++) { const v = positions[i + k]; if (v < mn[k]) mn[k] = v; if (v > mx[k]) mx[k] = v; } | |
| const cell = _lvCell({ min: mn, max: mx, tris: positions.length / 9 }); | |
| const c = MC.build(positions, { cell: cell, walkableNy: 0.7 }); | |
| level.buildMs = Math.round(performance.now() - t0); | |
| return c; | |
| } | |
| // [e30 coach] player footprint probe: centre + 8 points just OUTSIDE the wall radius (r + 3 cm). Muse's groundAtDisc samples at | |
| // 0.7 r, i.e. inside the cylinder, so a riser blocked the player (wallHit at r) before the step top was ever seen and | |
| // step-up/mantle only worked when a long frame jumped past it. Probing at r + 3 cm sees the top first. | |
| const _LV_PR = PLAYER_R + 0.03, _LV_D = _LV_PR * Math.SQRT1_2; | |
| const _LV_PROBE = [[0, 0], [_LV_PR, 0], [-_LV_PR, 0], [0, _LV_PR], [0, -_LV_PR], [_LV_D, _LV_D], [_LV_D, -_LV_D], [-_LV_D, _LV_D], [-_LV_D, -_LV_D]]; | |
| function _lvGround(x, z, maxY){ | |
| const C = level.collider; let best = null; | |
| for (let i = 0; i < 9; i++) { const g = C.groundAt(x + _LV_PROBE[i][0], z + _LV_PROBE[i][1], maxY); if (g != null && (best == null || g > best)) best = g; } | |
| return best; | |
| } | |
| function levelFloor(px, pz, fromY){ const g = _lvGround(px, pz, fromY + 0.55); return g == null ? -1e9 : g; } | |
| function levelBlockedAt(px, pz, feetY){ | |
| const C = level.collider; | |
| const g = _lvGround(px, pz, feetY + MANTLE_MAX + 0.05); | |
| const base = (g != null && g > feetY) ? g : feetY; | |
| if (C.wallHit(px, pz, PLAYER_R, base + 0.15, base + LV_H)) return feetY + 50; // wall: not steppable | |
| if (g != null && g > feetY + 0.02) return g; // ledge / step / mantle top | |
| return null; | |
| } | |
| function levelPushPlayer(){ | |
| const C = level.collider; | |
| for (let i = 0; i < 3; i++) { | |
| const p = C.pushOut(cam.pos[0], cam.pos[2], PLAYER_R, airY + STEP_UP + 0.05, airY + LV_H); | |
| if (!p || (!p[0] && !p[1])) break; | |
| cam.pos[0] += p[0]; cam.pos[2] += p[1]; | |
| } | |
| } | |
| function levelCanStand(x, z, r, baseY, h){ | |
| const C = level.collider; | |
| const g = C.groundAtDisc(x, z, r, baseY + 0.55); | |
| if (g == null || g < baseY - 1.2) return null; // no floor / cliff: AI stays on its level | |
| const b = Math.max(g, baseY); | |
| if (C.wallHit(x, z, r, b + 0.15, b + h)) return null; | |
| return g; | |
| } | |
| function _charBase(o, isDog){ return isDog ? (o.pos[1] - (o._halfY != null ? o._halfY : 0.25)) : (o.baseY || 0); } | |
| function _setCharBase(o, g, isDog){ if (isDog) o.pos[1] = g + (o._halfY != null ? o._halfY : 0.25); else { o.baseY = g; } } | |
| function levelMoveChar(o, x, z, r, h, isDog){ | |
| const base = _charBase(o, isDog); | |
| let g = levelCanStand(x, z, r, base, h); | |
| if (g != null) { o.pos[0] = x; o.pos[2] = z; _setCharBase(o, g, isDog); return true; } | |
| g = levelCanStand(x, o.pos[2], r, base, h); | |
| if (g != null) { o.pos[0] = x; _setCharBase(o, g, isDog); return true; } | |
| g = levelCanStand(o.pos[0], z, r, base, h); | |
| if (g != null) { o.pos[2] = z; _setCharBase(o, g, isDog); return true; } | |
| return false; | |
| } | |
| function _lvSpawnPoint(hx, hz){ try { return level.collider.findSpawn(hx, hz, {}); } catch (err) { console.warn('[level] findSpawn', err); return null; } } | |
| function _placePlayer(p){ cam.pos[0] = p[0]; cam.pos[2] = p[2]; airY = p[1]; velY = 0; onGround = true; onLadder = false; cam.pos[1] = standEye + airY; } | |
| function respawnInLevel(){ if (level.spawn) { _placePlayer(level.spawn); level.respawns++; flashHint('RESPAWN'); } } | |
| function activateLevel(){ | |
| const C = level.collider, st = C.stats; | |
| const S = level.stash = { | |
| nodes: sceneNodes.splice(0, sceneNodes.length), lamps: lamps.splice(0, lamps.length), pickups: ammoPickups.splice(0, ammoPickups.length), | |
| door: door.pos.slice(), ladder: ladder.pos.slice(), deck: ladderDeck.pos.slice(), bollard: lookProp.visible, | |
| hostile: { pos: hostile.pos.slice(), baseY: hostile.baseY }, patrol: { pos: patrolHostile.pos.slice(), baseY: patrolHostile.baseY, a: patrolHostile.waypointA.slice(), b: patrolHostile.waypointB.slice() }, | |
| dogs: dogs.map(d => d.pos.slice()), cam: [cam.pos[0], cam.pos[1], cam.pos[2], cam.yaw, cam.pitch], airY: airY | |
| }; | |
| refreshTargets(); | |
| door.pos[0] = 9999; door.pos[1] = -500; door.pos[2] = 9999; | |
| ladder.pos[0] = 9999; ladder.pos[2] = 9999; ladderDeck.pos[0] = 9999; ladderDeck.pos[2] = 9999; | |
| lookProp.visible = false; | |
| // spawn: named node (PlayerStart/spawn) snapped to the floor, else nearest valid spot to the scene centre | |
| const cx = (st.min[0] + st.max[0]) / 2, cz = (st.min[2] + st.max[2]) / 2; | |
| let sp = null; | |
| if (level.spawnNode) { | |
| const n = level.spawnNode; | |
| const g = C.groundAtDisc(n[0], n[2], PLAYER_R, n[1] + 1.0); | |
| if (g != null && !C.wallHit(n[0], n[2], PLAYER_R, g + 0.3, g + LV_H)) sp = [n[0], g, n[2]]; | |
| else sp = _lvSpawnPoint(n[0], n[2]); | |
| level.spawnSource = sp ? 'node' : 'search'; | |
| } | |
| if (!sp) { sp = _lvSpawnPoint(cx, cz); level.spawnSource = sp ? 'search' : 'none'; } | |
| if (!sp) sp = [cx, st.max[1] + 0.5, cz]; | |
| level.spawn = sp; | |
| _placePlayer(sp); | |
| cam.yaw = Math.atan2(cx - sp[0], -(cz - sp[2])) || 0; cam.pitch = -0.08; | |
| // enemies: move onto valid floor near the player (keep their AI); dogs keep their height offset | |
| const put = function(o, hx, hz, isDog){ const p = _lvSpawnPoint(hx, hz); if (!p) return false; o.pos[0] = p[0]; o.pos[2] = p[2]; _setCharBase(o, p[1], isDog); return true; }; | |
| const fx = Math.sin(cam.yaw), fz = -Math.cos(cam.yaw); | |
| put(hostile, sp[0] + fx * 9 + fz * 3, sp[2] + fz * 9 - fx * 3, false); | |
| if (put(patrolHostile, sp[0] + fx * 6 - fz * 4, sp[2] + fz * 6 + fx * 4, false)) { | |
| const a = patrolHostile.pos.slice(); const b2 = _lvSpawnPoint(a[0] - fz * 5, a[2] + fx * 5) || a; | |
| patrolHostile.waypointA = [a[0], a[1], a[2]]; patrolHostile.waypointB = [b2[0], a[1], b2[2]]; | |
| } | |
| for (let i = 0; i < dogs.length; i++) { const d = dogs[i]; if (d._halfY == null) d._halfY = S.dogs[i][1]; put(d, sp[0] + fx * 4 + fz * (2 + i), sp[2] + fz * 4 - fx * (2 + i), true); } | |
| level.active = true; | |
| flashHint('LEVEL: ' + ((importSlots.scene && importSlots.scene.info.name) || 'scene') + ' · colliders ' + st.tris + ' tris'); | |
| } | |
| function deactivateLevel(){ | |
| const S = level.stash; level.active = false; | |
| if (S) { | |
| for (const n of S.nodes) sceneNodes.push(n); for (const l of S.lamps) lamps.push(l); for (const p of S.pickups) ammoPickups.push(p); | |
| refreshTargets(); | |
| for (let k = 0; k < 3; k++) { door.pos[k] = S.door[k]; ladder.pos[k] = S.ladder[k]; ladderDeck.pos[k] = S.deck[k]; } | |
| lookProp.visible = cfg.bollard; | |
| for (let k = 0; k < 3; k++) { hostile.pos[k] = S.hostile.pos[k]; patrolHostile.pos[k] = S.patrol.pos[k]; } | |
| hostile.baseY = S.hostile.baseY; patrolHostile.baseY = S.patrol.baseY; patrolHostile.waypointA = S.patrol.a.slice(); patrolHostile.waypointB = S.patrol.b.slice(); | |
| dogs.forEach((d, i) => { for (let k = 0; k < 3; k++) d.pos[k] = S.dogs[i][k]; }); | |
| } | |
| level.stash = null; | |
| cam.pos[0] = BUILTIN_SPAWN[0]; cam.pos[2] = BUILTIN_SPAWN[2]; airY = 0; velY = 0; onGround = true; cam.pos[1] = standEye; cam.yaw = 0; cam.pitch = 0; | |
| applyConfig(); | |
| flashHint('BUILT-IN COLLIDERS'); | |
| } | |
| function syncLevel(){ | |
| const want = !!(level.collider && importSlots.scene && importSlots.scene.info && importSlots.scene.info.mode === 'level' && cfg.sceneColliders); | |
| if (want && !level.active) activateLevel(); | |
| else if (!want && level.active) deactivateLevel(); | |
| } | |
| const _setImported29 = setImportedModel, _clearImported29 = clearImportedModel; | |
| function setImportedModel30(slot, meshes, info, extra){ | |
| if (slot === 'scene' && level.active) deactivateLevel(); | |
| if (slot === 'scene') { level.collider = null; level.spawnNode = null; level.spawn = null; level.stats = null; } | |
| const out = _setImported29(slot, meshes, info); | |
| if (slot === 'scene' && extra && extra.collision && extra.collision.length >= 9) { | |
| level.collider = buildLevelCollider(extra.collision); | |
| level.spawnNode = extra.spawn || null; | |
| if (level.collider) { | |
| const s = level.collider.stats; | |
| level.stats = { tris: s.tris, walkable: s.walkable, walls: s.walls, cells: s.cells, buildMs: level.buildMs }; | |
| if (importSlots.scene) importSlots.scene.info.collider = level.stats; | |
| } else if (importSlots.scene) importSlots.scene.info.collider = null; | |
| } | |
| syncLevel(); | |
| if (slot === 'scene' && importSlots.scene) { | |
| importSlots.scene.info.levelActive = level.active; | |
| importSlots.scene.info.spawn = level.spawn ? level.spawn.map(v => +v.toFixed(3)) : null; | |
| importSlots.scene.info.spawnSource = level.active ? level.spawnSource : null; | |
| } | |
| return importSlots[slot] ? importSlots[slot].info : out; | |
| } | |
| function clearImportedModel30(slot){ | |
| if (slot === 'scene') { if (level.active) deactivateLevel(); level.collider = null; level.spawnNode = null; level.spawn = null; level.stats = null; } | |
| _clearImported29(slot); | |
| } | |
| function levelShotTest(eye, dir, bestT){ | |
| let hit = null; | |
| if (levelOn()) { const h = level.collider.raycast(eye[0], eye[1], eye[2], dir[0], dir[1], dir[2], 200); if (h && h.t < bestT) hit = h; } | |
| return hit; | |
| } | |
| function recordShot(eye, dir, t){ level.lastShot = { eye: eye.slice(), dir: dir.slice(), t: (t === Infinity ? null : t) }; } | |
| function e30LevelHit(best){ | |
| const st = HIT_STYLES.wall; | |
| showHitMarker(st); | |
| flashHint('SCENE HIT'); | |
| } | |
| // --- [e30] pyramid: its square AABB also covered empty air around the slopes (e.g. lamp1 seen from spawn read STONE HIT) --- | |
| const _PYR = [[-1.2,0,-1.2],[1.2,0,-1.2],[1.2,0,1.2],[-1.2,0,1.2],[0,1.8,0]]; | |
| const _PYR_F = [[0,1,4],[1,2,4],[2,3,4],[3,0,4],[0,3,2],[0,2,1]]; | |
| function _rayTri(o, d, a, b, c){ | |
| const e1 = [b[0]-a[0], b[1]-a[1], b[2]-a[2]], e2 = [c[0]-a[0], c[1]-a[1], c[2]-a[2]]; | |
| const p = [d[1]*e2[2]-d[2]*e2[1], d[2]*e2[0]-d[0]*e2[2], d[0]*e2[1]-d[1]*e2[0]]; | |
| const det = e1[0]*p[0] + e1[1]*p[1] + e1[2]*p[2]; | |
| if (Math.abs(det) < 1e-9) return null; | |
| const inv = 1 / det, s = [o[0]-a[0], o[1]-a[1], o[2]-a[2]]; | |
| const u = (s[0]*p[0] + s[1]*p[1] + s[2]*p[2]) * inv; if (u < 0 || u > 1) return null; | |
| const q = [s[1]*e1[2]-s[2]*e1[1], s[2]*e1[0]-s[0]*e1[2], s[0]*e1[1]-s[1]*e1[0]]; | |
| const v = (d[0]*q[0] + d[1]*q[1] + d[2]*q[2]) * inv; if (v < 0 || u + v > 1) return null; | |
| const t = (e2[0]*q[0] + e2[1]*q[1] + e2[2]*q[2]) * inv; | |
| return t > 1e-6 ? t : null; | |
| } | |
| function pyramidRayT(node, eye, dir){ | |
| const sc = (typeof node.scale === 'number') ? node.scale : 1, P = node.pos; | |
| const V = _PYR.map(v => [P[0] + v[0]*sc, P[1] + v[1]*sc, P[2] + v[2]*sc]); | |
| let best = null; | |
| for (const f of _PYR_F) { const t = _rayTri(eye, dir, V[f[0]], V[f[1]], V[f[2]]); if (t != null && (best == null || t < best)) best = t; } | |
| return best; | |
| } | |
| // --- [e30] crosshair + hit marker follow the game view (canvas) centre, not the window centre --- | |
| let _xhKey = ''; | |
| function syncCrosshair(){ | |
| const r = canvas.getBoundingClientRect(); | |
| const k = r.left + ',' + r.top + ',' + r.width + ',' + r.height; | |
| if (k === _xhKey) return; | |
| _xhKey = k; | |
| const x = (r.left + r.width / 2) + 'px', y = (r.top + r.height / 2) + 'px'; | |
| crossEl.style.left = x; crossEl.style.top = y; hitMarkEl.style.left = x; hitMarkEl.style.top = y; | |
| } | |
| window.addEventListener('scroll', function(){ _xhKey = ''; syncCrosshair(); }, true); | |
| window.addEventListener('resize', function(){ _xhKey = ''; syncCrosshair(); }); | |
| let _e30Proj = null, _e30View = null; | |
| function e30Frame(){ | |
| syncCrosshair(); | |
| if (levelOn()) { | |
| if (lookProp.visible) lookProp.visible = false; // K / bollard toggle has no place in a loaded level | |
| if (level.collider && airY < level.collider.stats.min[1] - 12) respawnInLevel(); // fell out of the level | |
| } | |
| } | |
| function e30Matrices(proj, view){ _e30Proj = proj; _e30View = view; e32Frustum(proj, view); } // [e32] frustum planes per frame | |
| function _mulV(m, v){ return [m[0]*v[0]+m[4]*v[1]+m[8]*v[2]+m[12]*v[3], m[1]*v[0]+m[5]*v[1]+m[9]*v[2]+m[13]*v[3], m[2]*v[0]+m[6]*v[1]+m[10]*v[2]+m[14]*v[3], m[3]*v[0]+m[7]*v[1]+m[11]*v[2]+m[15]*v[3]]; } | |
| window.MuseEngine29.version = 'e30-scene-level-1'; | |
| window.MuseEngine29.setImportedModel = setImportedModel30; | |
| window.MuseEngine29.clearImportedModel = clearImportedModel30; | |
| { const _sc29 = window.MuseEngine29.setConfig; window.MuseEngine29.setConfig = function(p, s){ const r = _sc29(p, s); syncLevel(); return Object.assign({}, cfg); }; } | |
| window.MuseEngine29.level = function(){ | |
| return { active: level.active, hasCollider: !!level.collider, stats: level.stats, spawn: level.spawn, spawnSource: level.spawnSource || null, | |
| spawnNode: level.spawnNode, respawns: level.respawns, sceneColliders: cfg.sceneColliders }; | |
| }; | |
| window.MuseEngine29.respawn = function(){ if (levelOn()) respawnInLevel(); else { cam.pos[0] = BUILTIN_SPAWN[0]; cam.pos[2] = BUILTIN_SPAWN[2]; airY = 0; velY = 0; } }; | |
| { const _st29 = window.MuseEngine29.status; window.MuseEngine29.status = function(){ const s = _st29(); s.level = window.MuseEngine29.level(); s.onGround = onGround; s.feetY = airY; return s; }; } | |
| if (window.MuseEngine29.test) { | |
| const T = window.MuseEngine29.test; | |
| T.placeFeet = function(x, feetY, z, yaw, pitch){ cam.pos[0] = x; cam.pos[2] = z; airY = feetY; velY = 0; onGround = false; cam.pos[1] = standEye + airY; if (yaw != null) cam.yaw = yaw; if (pitch != null) cam.pitch = pitch; }; | |
| T.lastShot = function(){ const s = level.lastShot; if (!s || s.t == null) return s; return { eye: s.eye, dir: s.dir, t: s.t, point: [s.eye[0]+s.dir[0]*s.t, s.eye[1]+s.dir[1]*s.t, s.eye[2]+s.dir[2]*s.t] }; }; | |
| T.project = function(x, y, z){ // world -> CSS pixel (page) coordinates of the game view | |
| if (!_e30Proj) return null; | |
| const c = _mulV(_e30Proj, _mulV(_e30View, [x, y, z, 1])); | |
| if (c[3] <= 0) return null; | |
| const r = canvas.getBoundingClientRect(); | |
| return [r.left + (c[0] / c[3] * 0.5 + 0.5) * r.width, r.top + (1 - (c[1] / c[3] * 0.5 + 0.5)) * r.height]; | |
| }; | |
| T.crosshair = function(){ const r = crossEl.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; }; | |
| T.ground = function(x, z, maxY){ return level.collider ? level.collider.groundAt(x, z, maxY == null ? 1e6 : maxY) : null; }; | |
| T.wallHit = function(x, z, y0, y1){ return level.collider ? level.collider.wallHit(x, z, PLAYER_R, y0, y1) : null; }; | |
| T.pyramidRayT = function(){ const n = targets.concat(level.stash ? level.stash.nodes : []).find(t => t.name === 'objPyramid'); return n ? pyramidRayT(n, cam.pos.slice(), [Math.sin(cam.yaw)*Math.cos(cam.pitch), Math.sin(cam.pitch), -Math.cos(cam.yaw)*Math.cos(cam.pitch)]) : null; }; | |
| } | |
| // ===================== [e30 coach] end of Phase 30 block ===================== | |
| // ===================== [e31 coach] Phase 31: animated characters (GPU skinning, clip roles, capsule + head/body hitboxes) ===================== | |
| // Muse-authored maths (engine/anim/*.js, reviewed + fixed by the coach, see game/muse-authored/phase31/FIXES-P31.md): | |
| // MuseSkeletonPose (sampling, crossfade, world + palette matrices), MuseClipMap (clip roles), MuseCapsuleFit (capsule, head sphere, ray tests), | |
| // MuseBoneBudget (joint palette <= E31_MAX_BONES). The importer (engine/ui/model-import.js toCharacterRig) builds the rig. | |
| // This block is coach plumbing: GPU buffers, per-actor animation state machine, hitboxes, death clip, API + test hooks. | |
| var e31 = { rig: null, actors: { hostile: null, patrol: null }, runSpeed: 2.2, idleSpeed: 0.18, fade: 0.2, deathHold: 1.2, | |
| overrides: {}, roles: null, lastHit: null, pendingHint: null, forcedSpeed: { hostile: null, patrol: null } }; | |
| const E31_ROLES = ['idle', 'walk', 'run', 'shoot', 'death']; | |
| function e31On(){ return !!(e31.rig && importSlots.character && importSlots.character.rig === e31.rig); } | |
| // column-major 4x4 helpers (C = A * B), independent of the engine's multiply() argument order | |
| function _m4mul(a, b){ const o = new Float32Array(16); for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) { o[c*4+r] = a[r]*b[c*4] + a[4+r]*b[c*4+1] + a[8+r]*b[c*4+2] + a[12+r]*b[c*4+3]; } return o; } | |
| function _m4TRy(x, y, z, yaw){ const c = Math.cos(yaw), s = Math.sin(yaw); return new Float32Array([c,0,-s,0, 0,1,0,0, s,0,c,0, x,y,z,1]); } | |
| function _m4Rx(a){ const c = Math.cos(a), s = Math.sin(a); return new Float32Array([1,0,0,0, 0,c,s,0, 0,-s,c,0, 0,0,0,1]); } | |
| function _m4S(x, y, z){ return new Float32Array([x,0,0,0, 0,y,0,0, 0,0,z,0, 0,0,0,1]); } | |
| function _m4pt(m, p){ return [m[0]*p[0]+m[4]*p[1]+m[8]*p[2]+m[12], m[1]*p[0]+m[5]*p[1]+m[9]*p[2]+m[13], m[2]*p[0]+m[6]*p[1]+m[10]*p[2]+m[14]]; } | |
| function _angLerp(a, b, t){ let d = b - a; while (d > Math.PI) d -= 2*Math.PI; while (d < -Math.PI) d += 2*Math.PI; return a + d * t; } | |
| function e31ActorOf(h){ return h === hostile ? e31.actors.hostile : h === patrolHostile ? e31.actors.patrol : null; } | |
| function e31Roles(){ | |
| const rig = e31.rig; if (!rig) return null; | |
| const CM = window.MuseClipMap; | |
| let m = null; | |
| try { m = (CM && CM.match) ? CM.match(rig.clipNames, { overrides: e31.overrides }) : null; } catch (err) { console.warn('[e31] clip match', err); } | |
| if (!m) m = rig.match || { roles: { idle: -1, walk: -1, run: -1, shoot: -1, death: -1 }, unmatched: [] }; | |
| e31.roles = m; | |
| if (importSlots.character) { importSlots.character.info.roles = Object.assign({}, m.roles); importSlots.character.info.unmatched = (m.unmatched || []).slice(); importSlots.character.info.overrides = Object.assign({}, e31.overrides); } | |
| return m; | |
| } | |
| function e31Clip(role){ | |
| const R = (e31.roles || {}).roles || {}; const g = (k) => (R[k] != null && R[k] >= 0) ? R[k] : -1; | |
| if (role === 'walk') { const w = g('walk'); return w >= 0 ? w : (g('run') >= 0 ? g('run') : g('idle')); } | |
| if (role === 'run') { const r = g('run'); return r >= 0 ? r : (g('walk') >= 0 ? g('walk') : g('idle')); } | |
| return g(role); | |
| } | |
| function e31MakeActor(h){ | |
| const SP = window.MuseSkeletonPose, rig = e31.rig, N = rig.skeleton.nodes.length; | |
| const a = { fader: new SP.Fader(rig.skeleton), pose: SP.createPose(N), world: new Float32Array(16 * N), palette: new Float32Array(12 * Math.max(1, rig.palette.slots)), | |
| role: null, clip: -1, speed: 0, last: [h.pos[0], h.pos[2]], yaw: 0, shootT: 0, deathT: -1, switches: 0, history: [], model: identity(), head: null, body: null }; | |
| SP.restPose(rig.skeleton, a.pose); | |
| return a; | |
| } | |
| function e31SetRig(rig){ | |
| e31.rig = rig || null; e31.actors.hostile = null; e31.actors.patrol = null; e31.overrides = {}; e31.lastHit = null; | |
| if (!rig) return; | |
| try { const saved = JSON.parse(localStorage.getItem('muse-engine-31.clipmap.' + (importSlots.character.info.name || '')) || 'null'); if (saved && typeof saved === 'object') e31.overrides = saved; } catch (_) {} | |
| e31Roles(); | |
| e31.actors.hostile = e31MakeActor(hostile); e31.actors.patrol = e31MakeActor(patrolHostile); | |
| } | |
| // ---- per-frame: speed -> role -> crossfade -> pose -> palette; hitboxes follow the animated head ---- | |
| function e31Tick(dt){ | |
| if (!e31On()) return; | |
| const SP = window.MuseSkeletonPose, rig = e31.rig, cap = rig.capsule; | |
| for (const kind of ['hostile', 'patrol']) { | |
| const h = kind === 'hostile' ? hostile : patrolHostile, a = e31.actors[kind]; | |
| if (!a || !h.alive) continue; | |
| const dx = h.pos[0] - a.last[0], dz = h.pos[2] - a.last[1]; | |
| a.last[0] = h.pos[0]; a.last[1] = h.pos[2]; | |
| let inst = dt > 0 ? Math.hypot(dx, dz) / dt : a.speed; | |
| if (inst > 15) inst = 0; // teleport (level activation / respawn), not movement | |
| if (dt > 0) a.speed += (inst - a.speed) * Math.min(1, dt * 8); | |
| if (dt > 0 && Math.hypot(dx, dz) > 1e-4 && inst <= 15) a.yaw = _angLerp(a.yaw, Math.atan2(dx, dz), Math.min(1, dt * 10)); | |
| else if (kind === 'hostile' && hostile.alerted && dt > 0) a.yaw = _angLerp(a.yaw, Math.atan2(cam.pos[0] - h.pos[0], cam.pos[2] - h.pos[2]), Math.min(1, dt * 6)); | |
| if (h.alive && !(h.dying > 0)) a.deathT = -1; | |
| let role; | |
| if (h.dying > 0 && e31Clip('death') >= 0 && cfg.deathFx) role = 'death'; | |
| else if (a.shootT > 0 && e31Clip('shoot') >= 0) { role = 'shoot'; a.shootT -= dt; } | |
| else { | |
| const run = a.role === 'run' ? a.speed > e31.runSpeed * 0.85 : a.speed > e31.runSpeed; | |
| const move = (a.role === 'walk' || a.role === 'run') ? a.speed > e31.idleSpeed * 0.6 : a.speed > e31.idleSpeed; | |
| role = run ? 'run' : move ? 'walk' : 'idle'; | |
| } | |
| const ci = e31Clip(role); | |
| if (role !== a.role || ci !== a.clip) { | |
| if (ci >= 0 && ci !== a.clip) { a.fader.play(ci, role === 'death' ? 0.12 : e31.fade, { loop: role !== 'death' && role !== 'shoot', speed: 1 }); a.switches++; } | |
| if (ci >= 0 && ci === a.clip && role === 'shoot') { a.fader.play(ci, 0, { loop: false }); } | |
| a.history.push({ role: role, clip: ci, t: +performance.now().toFixed(0) }); if (a.history.length > 24) a.history.shift(); | |
| a.role = role; a.clip = ci; | |
| } | |
| if (a.clip >= 0) a.fader.update(dt, rig.clips, a.pose); else SP.restPose(rig.skeleton, a.pose); | |
| SP.worldMatrices(rig.skeleton, a.pose, IDENT, a.world); | |
| SP.skinPalette(a.world, rig.palette.nodes, rig.palette.invBind, null, a.palette); | |
| // model matrix: placement * yaw * fit (feet at 0, capsule axis at x=z=0, metres, +Z forward) | |
| const F = rig.fitM; | |
| if (h.dying > 0 && role !== 'death') { // no death clip: Phase 29 fall-then-boom applied to the rig | |
| const hs = hostileDrawScale(h), k = 1 / Math.max(0.05, h.scale); | |
| a.model = _m4mul(_m4mul(_m4mul(_m4TRy(h.pos[0], (h.baseY || 0) - (hs.sink || 0), h.pos[2], 0), _m4Rx(hs.tip || 0)), _m4S(hs.xz * k, hs.y * k, hs.xz * k)), _m4mul(_m4TRy(0, 0, 0, a.yaw), F)); | |
| } else { | |
| let sink = 0; | |
| if (role === 'death') { const dur = rig.clips[a.clip].duration; sink = Math.max(0, a.deathT - dur - e31.deathHold * 0.4) * 0.9; } | |
| a.model = _m4mul(_m4TRy(h.pos[0], (h.baseY || 0) - sink, h.pos[2], a.yaw), F); | |
| } | |
| // hitboxes (world): body capsule (fitted from the bind pose) + head sphere riding on the head joint | |
| const base = h.baseY || 0; | |
| let headTop = cap.height; | |
| if (rig.head) { | |
| const Mh = _m4mul(a.world.subarray(16 * rig.head.node, 16 * rig.head.node + 16), rig.head.invBindM); | |
| const c = _m4pt(a.model, _m4pt(rig.a0M, _m4pt(Mh, rig.head.cRaw))); | |
| a.head = { c: c, r: rig.head.r }; | |
| headTop = c[1] - base - rig.head.r; // bottom of the head sphere (chin / neck) | |
| } else a.head = null; | |
| // body capsule: its top hemisphere ends just under the head sphere, so head shots are not swallowed by the body | |
| const yTop = Math.max(cap.radius * 1.05, Math.min(cap.height - cap.radius, headTop - cap.radius * 0.5)); | |
| a.body = { a: [h.pos[0], base + Math.min(cap.radius, yTop), h.pos[2]], b: [h.pos[0], base + yTop, h.pos[2]], r: cap.radius }; | |
| } | |
| if (e31.pendingHint) { flashHint(e31.pendingHint); e31.pendingHint = null; } | |
| } | |
| // death: the clip replaces fall-then-boom when the rig has one (hold the last frame, sink, despawn) | |
| const _tickDeath30 = tickHostileDeath; | |
| tickHostileDeath = function(h, dt, hint){ | |
| const a = e31On() ? e31ActorOf(h) : null; | |
| if (a && h.dying > 0 && cfg.deathFx && e31Clip('death') >= 0) { | |
| if (a.deathT < 0) a.deathT = 0; | |
| a.deathT += dt; | |
| const dur = e31.rig.clips[e31Clip('death')].duration; | |
| if (a.deathT >= dur + e31.deathHold) { h.dying = 0; h.alive = false; h.deathBoomPending = false; } | |
| return true; | |
| } | |
| return _tickDeath30(h, dt, hint); | |
| }; | |
| // attack beat: the shoot clip plays when an enemy lands a hit on the player | |
| const _updHostile30 = updateHostile, _updPatrol30 = updatePatrolHostile; | |
| updateHostile = function(dt){ const hp0 = playerHP; _updHostile30(dt); if (playerHP !== hp0) e31Shoot('hostile'); }; | |
| updatePatrolHostile = function(dt){ const hp0 = playerHP; _updPatrol30(dt); if (playerHP !== hp0) e31Shoot('patrol'); }; | |
| function e31Shoot(kind){ const a = e31.actors[kind]; if (!e31On() || !a) return false; const ci = e31Clip('shoot'); if (ci < 0) return false; a.shootT = e31.rig.clips[ci].duration; a.role = null; return true; } | |
| function e31ChaseSpeed(h, dist){ // animated hostiles sprint (run clip) when far, walk when close | |
| if (e31.forcedSpeed.hostile != null && h === hostile) return e31.forcedSpeed.hostile; | |
| if (!e31On() || h.speed <= 0 || e31Clip('run') < 0) return h.speed; | |
| return dist > 6 ? h.speed * 2.3 : h.speed; | |
| } | |
| function e31PatrolSpeed(){ return (e31.forcedSpeed.patrol != null) ? e31.forcedSpeed.patrol : patrolHostile.speed; } | |
| function e31R(h){ return e31On() ? e31.rig.capsule.radius : h.scale * 0.5; } | |
| function e31H(h){ return e31On() ? e31.rig.capsule.height : h.scale; } | |
| // ---- shooting: head sphere vs body capsule (nearest wins); head = HEADSHOT x3 damage ---- | |
| function e31Ray(h, eye, dir){ | |
| const a = e31ActorOf(h), CF = window.MuseCapsuleFit; | |
| if (!a || !a.body || !CF) return null; | |
| const tb = CF.rayCapsule(eye, dir, a.body.a, a.body.b, a.body.r); | |
| const th = a.head ? CF.raySphere(eye, dir, a.head.c, a.head.r) : -1; | |
| let t = null, zone = null; | |
| if (tb >= 0) { t = tb; zone = 'body'; } | |
| if (th >= 0 && (t == null || th <= t + 1e-6)) { t = th; zone = 'head'; } | |
| h._zone = zone; | |
| return t; | |
| } | |
| function e31Damage(h){ if (!e31On()) return 1; return h._zone === 'head' ? 3 : 1; } | |
| function e31AfterShot(best){ | |
| if (!e31On() || !best || !(best.isHostile || best.isPatrol)) return; | |
| const h = best.isHostile ? hostile : patrolHostile; | |
| e31.lastHit = { kind: best.isHostile ? 'hostile' : 'patrol', zone: h._zone, hp: h.hp, down: h.hp <= 0, t: +performance.now().toFixed(0) }; | |
| if (h._zone === 'head') { e31.pendingHint = h.hp <= 0 ? 'HEADSHOT · ' + (best.isHostile ? 'HOSTILE' : 'PATROL') + ' DOWN' : 'HEADSHOT'; flashHint(e31.pendingHint); } | |
| } | |
| // ---- draw: skinned parts with the actor's palette (main + shadow pass) ---- | |
| function e31Draw(kind, flashCol, depth){ | |
| if (!e31On()) return false; | |
| if (kind === 'dog') return e32DrawDog(flashCol, depth); // [e32] dogs use the rig when 'Dogs use the loaded character' is on | |
| const a = e31.actors[kind]; if (!a) return false; | |
| const parts = importSlots.character.parts; | |
| const locI = depth ? d_aSkinIdx : aSkinIdx, locW = depth ? d_aSkinW : aSkinW; | |
| gl.useProgram(depth ? skinDepthProgram : skinProgram); // skinned variant (the plain programs have no skinning code) | |
| gl.uniform4fv(depth ? d_uBones : uBones, a.palette); | |
| for (const part of parts) { | |
| if (!part.skinVbo) continue; | |
| const M = part.A ? _m4mul(a.model, part.A) : a.model; | |
| gl.bindBuffer(gl.ARRAY_BUFFER, part.skinVbo); | |
| if (locI >= 0) { gl.enableVertexAttribArray(locI); gl.vertexAttribPointer(locI, 4, gl.FLOAT, false, 32, 0); } | |
| if (locW >= 0) { gl.enableVertexAttribArray(locW); gl.vertexAttribPointer(locW, 4, gl.FLOAT, false, 32, 16); } | |
| if (depth) drawMeshDepth(part.mesh, M); | |
| else if (flashCol) drawTexturedPart({ mesh: part.mesh, color: flashCol, spec: FLASH_SPEC, shin: 40, useTex: false, local: null }, M); | |
| else drawTexturedPart(part, M); | |
| } | |
| if (locI >= 0) gl.disableVertexAttribArray(locI); | |
| if (locW >= 0) gl.disableVertexAttribArray(locW); | |
| if (!depth) gl.uniform1f(uUseTex, 0.0); | |
| gl.useProgram(depth ? depthProgram : program); | |
| return true; | |
| } | |
| // ---- import hook: skin buffers + rig ---- | |
| const _setImported30 = setImportedModel30, _clearImported30 = clearImportedModel30; | |
| function _e31FreeSkin(){ const s = importSlots.character; if (s) for (const p of s.parts) if (p.skinVbo) { try { gl.deleteBuffer(p.skinVbo); } catch (_) {} p.skinVbo = null; } } | |
| function setImportedModel31(slot, meshes, info, extra){ | |
| if (slot === 'character') { _e31FreeSkin(); e31SetRig(null); } | |
| const out = _setImported30(slot, meshes, info, extra); | |
| if (slot === 'character' && extra && extra.rig && importSlots.character) { | |
| const rig = extra.rig, s = importSlots.character; | |
| if (!window.MuseSkeletonPose) { console.warn('[e31] skeleton-pose.js missing: rig drawn static'); return out; } | |
| s.parts.forEach(function(p, i){ | |
| const m = meshes[i], n = m.positions.length / 3, d = new Float32Array(n * 8); | |
| for (let v = 0; v < n; v++) for (let k = 0; k < 4; k++) { d[v*8+k] = m.skinIndex[v*4+k]; d[v*8+4+k] = m.skinWeight[v*4+k]; } | |
| p.skinVbo = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, p.skinVbo); gl.bufferData(gl.ARRAY_BUFFER, d, gl.STATIC_DRAW); | |
| p.A = m.A ? new Float32Array(m.A) : null; | |
| }); | |
| rig.fitM = new Float32Array(rig.fit); rig.a0M = new Float32Array(rig.a0); | |
| if (rig.head) rig.head.invBindM = new Float32Array(rig.head.invBind); | |
| s.rig = rig; | |
| e31SetRig(rig); | |
| s.info.gpuSkinning = true; s.info.maxBonesGPU = E31_MAX_BONES; | |
| flashHint('ANIMATED CHARACTER ' + (info.name || '') + ' · ' + rig.clips.length + ' clips · ' + rig.joints.count + ' bones'); | |
| } | |
| return importSlots[slot] ? importSlots[slot].info : out; | |
| } | |
| function clearImportedModel31(slot){ if (slot === 'character') { _e31FreeSkin(); e31SetRig(null); } _clearImported30(slot); } | |
| function e31ActorStatus(kind){ | |
| const a = e31.actors[kind], h = kind === 'hostile' ? hostile : patrolHostile; if (!a) return null; | |
| const st = a.fader.state ? a.fader.state() : {}; | |
| return { role: a.role, clip: a.clip, clipName: a.clip >= 0 ? e31.rig.clips[a.clip].name : null, speed: +a.speed.toFixed(3), yaw: +a.yaw.toFixed(3), | |
| weight: st.weight != null ? +(+st.weight).toFixed(3) : null, fading: !!st.fading, time: st.time != null ? +(+st.time).toFixed(3) : null, done: !!st.done, | |
| switches: a.switches, deathT: +a.deathT.toFixed(3), feetY: +(h.baseY || 0).toFixed(3), alive: h.alive, dying: h.dying, hp: h.hp, | |
| head: a.head ? { c: a.head.c.map(v => +v.toFixed(3)), r: +a.head.r.toFixed(3) } : null, | |
| body: a.body ? { a: a.body.a.map(v => +v.toFixed(3)), b: a.body.b.map(v => +v.toFixed(3)), r: +a.body.r.toFixed(3) } : null, | |
| history: a.history.slice(-10) }; | |
| } | |
| window.MuseEngine29.version = 'e31-anim-chars-1'; | |
| window.MuseEngine29.setImportedModel = setImportedModel31; | |
| window.MuseEngine29.clearImportedModel = clearImportedModel31; | |
| window.MuseEngine29.skinInfo = function(){ return { maxBones: E31_MAX_BONES, maxVertexUniformVectors: E31_MAXVU }; }; | |
| window.MuseEngine29.anim = function(){ | |
| if (!e31On()) return { active: false, maxBones: E31_MAX_BONES }; | |
| const rig = e31.rig; | |
| return { active: true, maxBones: E31_MAX_BONES, clips: rig.clipNames.slice(), clipDurations: rig.clips.map(c => +c.duration.toFixed(3)), roles: Object.assign({}, e31.roles.roles), unmatched: (e31.roles.unmatched || []).slice(), | |
| overrides: Object.assign({}, e31.overrides), joints: rig.joints.count, paletteSlots: rig.palette.slots, capsule: rig.capsule, head: rig.head ? { bone: rig.head.name, r: rig.head.r, c: rig.head.c } : null, | |
| runSpeed: e31.runSpeed, fade: e31.fade, lastHit: e31.lastHit, hostile: e31ActorStatus('hostile'), patrol: e31ActorStatus('patrol') }; | |
| }; | |
| window.MuseEngine29.setClipOverride = function(role, index){ // index: clip index, -1 = none, null/undefined = auto | |
| if (E31_ROLES.indexOf(role) < 0) throw new Error('role must be one of ' + E31_ROLES.join('|')); | |
| if (index == null || index === 'auto') delete e31.overrides[role]; else e31.overrides[role] = index | 0; | |
| if (e31On()) { try { localStorage.setItem('muse-engine-31.clipmap.' + (importSlots.character.info.name || ''), JSON.stringify(e31.overrides)); } catch (_) {} e31Roles(); for (const k in e31.actors) if (e31.actors[k]) e31.actors[k].role = null; } | |
| return e31.roles ? Object.assign({}, e31.roles.roles) : null; | |
| }; | |
| window.MuseEngine29.animConfig = function(o){ o = o || {}; if (o.runSpeed > 0) e31.runSpeed = +o.runSpeed; if (o.fade >= 0) e31.fade = +o.fade; return { runSpeed: e31.runSpeed, fade: e31.fade }; }; | |
| { const _st30 = window.MuseEngine29.status; window.MuseEngine29.status = function(){ const s = _st30(); s.anim = window.MuseEngine29.anim(); return s; }; } | |
| if (window.MuseEngine29.test) { | |
| const T = window.MuseEngine29.test; | |
| T.charShoot = function(kind){ return e31Shoot(kind || 'hostile'); }; | |
| T.charSpeed = function(kind, v){ e31.forcedSpeed[kind] = (v == null ? null : +v); return e31.forcedSpeed[kind]; }; | |
| T.aimAtPoint = function(x, y, z){ const dx = x - cam.pos[0], dz = z - cam.pos[2], dy = y - cam.pos[1]; cam.yaw = Math.atan2(dx, -dz); cam.pitch = Math.atan2(dy, Math.hypot(dx, dz)); return [cam.yaw, cam.pitch]; }; | |
| T.headPoint = function(kind){ const a = e31.actors[kind || 'hostile']; return a && a.head ? a.head.c.slice() : null; }; | |
| T.bodyPoint = function(kind){ const a = e31.actors[kind || 'hostile']; if (!a || !a.body) return null; return [a.body.a[0], (a.body.a[1] + a.body.b[1]) * 0.5, a.body.a[2]]; }; | |
| T.resetEnemies = function(){ for (const h of [hostile, patrolHostile]) { h.alive = true; h.dying = 0; h.hp = 5; h.deathBoomPending = false; h.hitFlash = 0; } for (const k in e31.actors) if (e31.actors[k]) { e31.actors[k].deathT = -1; e31.actors[k].role = null; } }; | |
| } | |
| // ===================== [e31 coach] end of Phase 31 block ===================== | |
| // ===================== [e32 coach] Phase 32: Engine 32 RC (model check, root motion toggle, dogs use the character, perf) ===================== | |
| // Muse-authored helpers (reviewed + fixed by the coach, see game/muse-authored/phase32/FIXES-P32.md): | |
| // MuseModelCheck (engine/check/model-check.js): warnings for the Model check panel; MuseRootMotion (engine/anim/root-motion.js): the | |
| // importer measures root motion and builds in-place clips; MuseSetup (engine/io/setup-schema.js): setup JSON + drop planner (used by engine/ui/e32-ui.js). | |
| // This block is coach plumbing: live clip-set switch, dog actors driven by the loaded rig, frustum culling of batched scene parts, | |
| // the modelCheck() API and test hooks. | |
| const E32_VERSION = 'e32-rc-1'; | |
| const E32_DOG_SCALE = 0.5; // the loaded character drawn at half size as the dog | |
| let _e32Dog = null; // set by the dog draw loops just before drawCharacter('dog', ...) | |
| const e32 = { dogActors: new Map(), frame: 0, planes: null, drawn: 0, culled: 0, lastDrawn: 0, lastCulled: 0 }; | |
| function e32ApplyClips(){ const r = e31.rig; if (r && r.clipsRaw && r.clipsStripped) r.clips = cfg.stripRootMotion ? r.clipsStripped : r.clipsRaw; } | |
| function e32DogOn(){ return e31On() && !!cfg.importDog; } | |
| function e32DogFeet(d){ return d.pos[1] - (d._halfY != null ? d._halfY : 0.25); } | |
| function e32DogActor(d){ | |
| let a = e32.dogActors.get(d); | |
| if (!a || a.rig !== e31.rig) { a = e31MakeActor(d); a.rig = e31.rig; e32.dogActors.set(d, a); } | |
| return a; | |
| } | |
| function e32Tick(dt){ | |
| e32ApplyClips(); | |
| if (!e32DogOn()) { if (e32.dogActors.size) e32.dogActors.clear(); return; } | |
| const SP = window.MuseSkeletonPose, rig = e31.rig, cap = rig.capsule, s = E32_DOG_SCALE; | |
| for (const d of dogs) { | |
| if (!d.alive || !cfg.dogs) continue; | |
| const a = e32DogActor(d); | |
| const dx = d.pos[0] - a.last[0], dz = d.pos[2] - a.last[1]; | |
| a.last[0] = d.pos[0]; a.last[1] = d.pos[2]; | |
| let inst = dt > 0 ? Math.hypot(dx, dz) / dt : a.speed; | |
| if (inst > 15) inst = 0; | |
| if (dt > 0) a.speed += (inst - a.speed) * Math.min(1, dt * 8); | |
| if (dt > 0 && Math.hypot(dx, dz) > 1e-4 && inst <= 15) a.yaw = _angLerp(a.yaw, Math.atan2(dx, dz), Math.min(1, dt * 10)); | |
| const run = a.role === 'run' ? a.speed > 1.4 : a.speed > 1.6; | |
| const move = (a.role === 'walk' || a.role === 'run') ? a.speed > e31.idleSpeed * 0.6 : a.speed > e31.idleSpeed; | |
| const role = run ? 'run' : move ? 'walk' : 'idle'; | |
| const ci = e31Clip(role); | |
| if (role !== a.role || ci !== a.clip) { | |
| if (ci >= 0 && ci !== a.clip) { a.fader.play(ci, e31.fade, { loop: true, speed: 1 }); a.switches++; } | |
| a.history.push({ role: role, clip: ci, t: +performance.now().toFixed(0) }); if (a.history.length > 24) a.history.shift(); | |
| a.role = role; a.clip = ci; | |
| } | |
| if (a.clip >= 0) a.fader.update(dt, rig.clips, a.pose); else SP.restPose(rig.skeleton, a.pose); | |
| SP.worldMatrices(rig.skeleton, a.pose, IDENT, a.world); | |
| SP.skinPalette(a.world, rig.palette.nodes, rig.palette.invBind, null, a.palette); | |
| const feet = e32DogFeet(d); | |
| a.model = _m4mul(_m4mul(_m4TRy(d.pos[0], feet, d.pos[2], a.yaw), _m4S(s, s, s)), rig.fitM); | |
| a.body = { a: [d.pos[0], feet + cap.radius * s, d.pos[2]], b: [d.pos[0], feet + Math.max(cap.radius * s * 1.05, (cap.height - cap.radius) * s), d.pos[2]], r: cap.radius * s }; | |
| } | |
| } | |
| function e32DrawActor(a, flashCol, depth){ | |
| const parts = importSlots.character.parts; | |
| const locI = depth ? d_aSkinIdx : aSkinIdx, locW = depth ? d_aSkinW : aSkinW; | |
| gl.useProgram(depth ? skinDepthProgram : skinProgram); | |
| gl.uniform4fv(depth ? d_uBones : uBones, a.palette); | |
| for (const part of parts) { | |
| if (!part.skinVbo) continue; | |
| const M = part.A ? _m4mul(a.model, part.A) : a.model; | |
| gl.bindBuffer(gl.ARRAY_BUFFER, part.skinVbo); | |
| if (locI >= 0) { gl.enableVertexAttribArray(locI); gl.vertexAttribPointer(locI, 4, gl.FLOAT, false, 32, 0); } | |
| if (locW >= 0) { gl.enableVertexAttribArray(locW); gl.vertexAttribPointer(locW, 4, gl.FLOAT, false, 32, 16); } | |
| if (depth) drawMeshDepth(part.mesh, M); | |
| else if (flashCol) drawTexturedPart({ mesh: part.mesh, color: flashCol, spec: FLASH_SPEC, shin: 40, useTex: false, local: null }, M); | |
| else drawTexturedPart(part, M); | |
| } | |
| if (locI >= 0) gl.disableVertexAttribArray(locI); | |
| if (locW >= 0) gl.disableVertexAttribArray(locW); | |
| if (!depth) gl.uniform1f(uUseTex, 0.0); | |
| gl.useProgram(depth ? depthProgram : program); | |
| return true; | |
| } | |
| function e32DrawDog(flashCol, depth){ | |
| const d = _e32Dog; _e32Dog = null; | |
| if (!d || !e32DogOn()) return false; | |
| const a = e32.dogActors.get(d); if (!a || a.rig !== e31.rig || !a.model) return false; | |
| return e32DrawActor(a, flashCol, depth); | |
| } | |
| function e32DogRay(d, eye, dir, min, max){ | |
| if (!e32DogOn()) return rayAABB(eye, dir, min, max); | |
| const a = e32.dogActors.get(d), CF = window.MuseCapsuleFit; | |
| if (!a || !a.body || !CF) return rayAABB(eye, dir, min, max); | |
| const t = CF.rayCapsule(eye, dir, a.body.a, a.body.b, a.body.r); | |
| return t >= 0 ? t : null; | |
| } | |
| // ---- frustum culling for batched scene parts (main pass only; the shadow pass still draws everything) ---- | |
| function e32Frustum(proj, view){ | |
| e32.lastDrawn = e32.drawn; e32.lastCulled = e32.culled; e32.drawn = 0; e32.culled = 0; e32.frame++; | |
| if (!proj || !view) { e32.planes = null; return; } | |
| const m = _m4mul(proj, view), P = []; | |
| const row = (r) => [m[r], m[4 + r], m[8 + r], m[12 + r]]; | |
| const r0 = row(0), r1 = row(1), r2 = row(2), r3 = row(3); | |
| for (const [a, sgn] of [[r0, 1], [r0, -1], [r1, 1], [r1, -1], [r2, 1], [r2, -1]]) { | |
| const p = [r3[0] + sgn * a[0], r3[1] + sgn * a[1], r3[2] + sgn * a[2], r3[3] + sgn * a[3]]; | |
| const l = Math.hypot(p[0], p[1], p[2]) || 1; P.push([p[0] / l, p[1] / l, p[2] / l, p[3] / l]); | |
| } | |
| e32.planes = P; | |
| } | |
| function e32Visible(part){ | |
| const b = part.bs, P = e32.planes; | |
| if (!b || !P) { e32.drawn++; return true; } | |
| for (let i = 0; i < 6; i++) { const p = P[i]; if (p[0] * b[0] + p[1] * b[1] + p[2] * b[2] + p[3] < -b[3]) { e32.culled++; return false; } } | |
| e32.drawn++; return true; | |
| } | |
| // ---- Model check: facts from the importer + live engine state -> MuseModelCheck.analyze ---- | |
| function e32ModelCheck(slot){ | |
| const s = importSlots[slot]; if (!s) return null; | |
| const info = s.info || {}; | |
| const f = JSON.parse(JSON.stringify(info.check || { kind: slot, name: info.name })); | |
| if (slot === 'scene' && info.mode === 'level') f.scene = { mode: 'level', colliderTris: (info.collider || {}).tris || 0, walkableTris: (info.collider || {}).walkable || 0, | |
| spawn: info.spawnSource || (info.spawnNode ? 'node' : 'search') }; | |
| const rigOK = !!(s.rig && s.rig.clipsStripped && s.rig.clipsStripped !== s.rig.clipsRaw); | |
| if (f.rig && f.rig.clips) { | |
| const R = (e31.roles && e31.roles.roles) || info.roles || {}; | |
| f.rig.roles = Object.assign({}, R); | |
| f.rig.clips.forEach(function(c){ c.stripped = !!cfg.stripRootMotion && rigOK; }); | |
| } | |
| const MC = window.MuseModelCheck; | |
| let res; | |
| try { res = MC ? MC.analyze(f) : null; } catch (err) { console.warn('[e32] model check', err); res = null; } | |
| if (!res) res = { level: 'ok', warnings: [], summary: { errors: 0, warns: 0, infos: 0, codes: [] }, unavailable: true }; | |
| // [e32 scope change] importer notes (extraChecks from extra readers) + MuseTextureBudget (LARGE_TEXTURES / TEXTURE_MEMORY / NPOT_TEXTURES) | |
| let budget = null; const extra = (f.extraChecks || []).slice(); | |
| const TB = window.MuseTextureBudget; | |
| if (TB && f.textures && f.textures.sizes) { try { budget = TB.analyze(f.textures.sizes); extra.push.apply(extra, budget.warnings); } catch (err) { console.warn('[e32] texture budget', err); } } | |
| if (extra.length) { | |
| const ord = { error: 0, warn: 1, info: 2 }, all = res.warnings.concat(extra.filter(function(w){ return w && ord[w.level] != null; })); | |
| all.forEach(function(w, i){ w._i = i; }); all.sort(function(a, b){ return (ord[a.level] - ord[b.level]) || (a._i - b._i); }); all.forEach(function(w){ delete w._i; }); | |
| const sm = { errors: 0, warns: 0, infos: 0, codes: all.map(function(w){ return w.code; }) }; | |
| all.forEach(function(w){ if (w.level === 'error') sm.errors++; else if (w.level === 'warn') sm.warns++; else sm.infos++; }); | |
| res = Object.assign({}, res, { warnings: all, summary: sm, level: sm.errors ? 'error' : sm.warns ? 'warn' : 'ok' }); | |
| } | |
| const takes = ((f.rig && f.rig.clips) || []).map(function(c){ | |
| const e = MC ? MC.estimateFps(c.times || []) : { fps: 0, frames: (c.times || []).length, standard: false }; | |
| return { name: c.name, duration: c.duration, fps: e.fps, frames: e.frames, standard: e.standard, keys: (c.times || []).length, rootMotionM: c.rootMotionM || 0, stripped: !!c.stripped }; | |
| }); | |
| if (f.rig && f.rig.clips) f.rig.clips.forEach(function(c){ delete c.times; }); | |
| return { slot: slot, name: info.name, mode: info.mode, facts: f, level: res.level, warnings: res.warnings, summary: res.summary, takes: takes, textureBudget: budget ? { count: budget.count, decodedMB: budget.totalDecodedMB, uploadMB: budget.totalUploadMB, largest: budget.largest } : null, | |
| drawParts: info.drawParts || (s.parts ? s.parts.length : 0), srcParts: info.srcParts || (s.parts ? s.parts.length : 0), rootNode: info.rootMotion ? info.rootMotion.node : null }; | |
| } | |
| { // setImportedModel: apply the clip set for the current toggle and drop old dog actors | |
| const _set31 = window.MuseEngine29.setImportedModel; | |
| window.MuseEngine29.setImportedModel = function(slot, meshes, info, extra){ const r = _set31(slot, meshes, info, extra); if (slot === 'character') { e32.dogActors.clear(); e32ApplyClips(); } return r; }; | |
| const _clr31 = window.MuseEngine29.clearImportedModel; | |
| window.MuseEngine29.clearImportedModel = function(slot){ if (slot === 'character') e32.dogActors.clear(); return _clr31(slot); }; | |
| } | |
| window.MuseEngine29.version = E32_VERSION; | |
| window.MuseEngine29.releaseLabel = 'Engine 32 RC'; | |
| window.MuseEngine29.modelCheck = e32ModelCheck; | |
| window.MuseEngine29.perf = function(){ const s = importSlots.scene; return { sceneDrawParts: s ? s.parts.length : 0, sceneSrcParts: s && s.info ? (s.info.srcParts || s.parts.length) : 0, | |
| drawnLastFrame: e32.lastDrawn, culledLastFrame: e32.lastCulled, sharedTextures: s ? s.parts.filter(function(p){ return p.sharedTex; }).length : 0, | |
| textures: s ? new Set(s.parts.filter(function(p){ return p.tex; }).map(function(p){ return p.tex; })).size : 0 }; }; | |
| { const _st31 = window.MuseEngine29.status; window.MuseEngine29.status = function(){ const s = _st31(); | |
| s.e32 = { version: E32_VERSION, stripRootMotion: !!cfg.stripRootMotion, dogsUseCharacter: e32DogOn(), dogActors: e32.dogActors.size }; return s; }; } | |
| if (window.MuseEngine29.test) { | |
| const T = window.MuseEngine29.test; | |
| T.rootPos = function(kind){ // engine-space position of the rig's root-motion node for an actor ('hostile' | 'patrol' | 'dog') | |
| const rig = e31.rig; if (!rig || !rig.rootMotion || rig.rootMotion.node < 0) return null; | |
| const a = kind === 'dog' ? e32.dogActors.get(dogs[0]) : e31.actors[kind || 'hostile']; if (!a || !a.model) return null; | |
| const w = a.world, n = rig.rootMotion.node; | |
| return _m4pt(a.model, _m4pt(rig.a0M, [w[16 * n + 12], w[16 * n + 13], w[16 * n + 14]])); | |
| }; | |
| T.dogActor = function(){ const d = dogs[0], a = e32.dogActors.get(d); if (!a) return null; | |
| return { role: a.role, clip: a.clip, clipName: a.clip >= 0 ? e31.rig.clips[a.clip].name : null, speed: +a.speed.toFixed(3), switches: a.switches, pos: d.pos.slice(), feet: +e32DogFeet(d).toFixed(3), | |
| body: a.body ? { a: a.body.a.map(function(v){ return +v.toFixed(3); }), b: a.body.b.map(function(v){ return +v.toFixed(3); }), r: +a.body.r.toFixed(3) } : null }; }; | |
| T.dogPoint = function(){ const a = e32.dogActors.get(dogs[0]); if (!a || !a.body) { const d = dogs[0]; return d.pos.slice(); } return [a.body.a[0], (a.body.a[1] + a.body.b[1]) * 0.5, a.body.a[2]]; }; | |
| T.dogs = function(){ return dogs.map(function(d){ return { pos: d.pos.slice(), alive: d.alive, hp: d.hp }; }); }; | |
| T.moveDog = function(x, z){ const d = dogs[0]; d.pos[0] = x; d.pos[2] = z; d.target = [x, 0, z]; const a = e32.dogActors.get(d); if (a) { a.last[0] = x; a.last[1] = z; } return d.pos.slice(); }; | |
| } | |
| // ===================== [e32 coach] end of Phase 32 block ===================== | |
| let _charShapeInit = false; | |
| function render(now){ | |
| if (!_charShapeInit) { _charShapeInit = true; applyCharShapePack(); } | |
| resize(); | |
| e30Frame(); // [e30] | |
| const dt = Math.min(0.05, (now - last)/1000); | |
| last = now; | |
| if (shootCooldown > 0) shootCooldown -= dt; | |
| if (hitFlashTimer > 0) hitFlashTimer -= dt; | |
| if (hurtFlash > 0) hurtFlash -= dt; | |
| if (muzzleFlash > 0) muzzleFlash -= dt; | |
| if (reloading) { | |
| reloadTimer -= dt; | |
| if (reloadTimer <= 0) { | |
| reloading = false; | |
| const take = shareAmmoIntoMag(); | |
| flashHint(take > 0 ? ('RELOADED +' + take) : 'RELOADED'); | |
| sfxReloadEnd(); | |
| } | |
| } | |
| if (!uiBlocked) updateCamera(dt); // [e29] paused while an overlay is open | |
| if (!orbit.enabled && !uiBlocked) { | |
| resolveCollision(); | |
| checkAmmoPickups(); | |
| if (cfg.enemies) { // [e29] gameplay toggle + engine part (AI update) | |
| if (cfg.enemyAI) { updateHostile(dt); updatePatrolHostile(dt); } | |
| else { tickHostileDeath(hostile, dt); tickHostileDeath(patrolHostile, dt); } | |
| } | |
| if (cfg.dogs && cfg.enemyAI) updateDogs(dt); // [e29] | |
| for (const n of sceneNodes) { | |
| if (n.dying > 0) { | |
| n.dying -= dt; | |
| if (n.dying <= 0) { n.dying = 0; n.hp = 0; } | |
| } | |
| } | |
| const allCratesDown = targets.filter(t => t.name && /^(crate\d|obj)/.test(t.name)).every(t => t.hp <= 0); | |
| if (hostile.alive === false && patrolHostile.alive === false && allCratesDown && !wonOnce){ | |
| wonOnce = true; | |
| flashHint('WIN'); | |
| } | |
| } | |
| e31Tick(uiBlocked ? 0 : dt); // [e31] poses + palettes + hitboxes | |
| e32Tick(uiBlocked ? 0 : dt); // [e32] clip set + dog actors | |
| frames++; fpsTime += dt; | |
| if (fpsTime >= 0.5){ fps = Math.round(frames / fpsTime); frames=0; fpsTime=0; } | |
| const litCount = lamps.filter(lampLit).length; | |
| const modeStr = nightMode ? ('NIGHT L' + litCount + '/' + lamps.length + (litCount===0?' MOON':'')) : 'DAY'; | |
| const reloadStr = reloading ? ' RELOAD' : ''; | |
| const crouchStr = crouching ? ' CROUCH' : ''; | |
| const dogsAlive = dogs.filter(d=>d.alive).length; | |
| fpsEl.textContent = fps + ' FPS · ' + modeStr + ' · HP ' + playerHP + ' · AMMO ' + ammoMag + '/' + ammoReserve + reloadStr + crouchStr + ' · DOGS ' + dogsAlive + ' · Score ' + score; | |
| const aspect = canvas.width / canvas.height; | |
| const proj = perspective(Math.PI/4, aspect, 0.1, 100); | |
| let view; | |
| if (orbit.enabled) { | |
| view = lookAt(cam.pos, orbit.target, [0,1,0]); | |
| } else { | |
| const cp = Math.cos(cam.pitch), sp = Math.sin(cam.pitch); | |
| const target = [ | |
| cam.pos[0] + Math.sin(cam.yaw) * cp, | |
| cam.pos[1] + sp, | |
| cam.pos[2] + (-Math.cos(cam.yaw) * cp) | |
| ]; | |
| view = lookAt(cam.pos, target, [0,1,0]); | |
| } | |
| e30Matrices(proj, view); // [e30] | |
| const LE = curLook.env; // [e29] look env | |
| const lightDirDay = LE.sunDirDay; | |
| const lightDirNight = LE.sunDirNight; | |
| const lightDir = nightMode ? lightDirNight : lightDirDay; | |
| const lightPos = [0,0,0]; | |
| const shadowC = levelOn() ? [cam.pos[0], cam.pos[1] - standEye, cam.pos[2]] : orbit.target; // [e30] shadows follow the player in a level | |
| for (let i=0;i<3;i++) lightPos[i] = shadowC[i] - lightDir[i]*12; | |
| const lightView = lookAt(lightPos, shadowC, [0,1,0]); | |
| const lightProj = ortho(-10,10,-10,10,1,35); | |
| const lightVP = multiply(lightProj, lightView); | |
| const lightMVP = lightVP; | |
| const dayClear = LE.dayClear; | |
| const litForSky = nightMode ? lamps.filter(lampLit).length : 4; | |
| const nightClear = [ | |
| LE.nightClearBase[0] + litForSky * LE.nightClearPerLamp[0], | |
| LE.nightClearBase[1] + litForSky * LE.nightClearPerLamp[1], | |
| LE.nightClearBase[2] + litForSky * LE.nightClearPerLamp[2] | |
| ]; | |
| const clearCol = nightMode ? nightClear : dayClear; | |
| gl.clearColor(clearCol[0], clearCol[1], clearCol[2], 1.0); | |
| if (useShadowMap) { | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, shadowFBO); | |
| gl.viewport(0,0,SHADOW_SIZE,SHADOW_SIZE); | |
| gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); | |
| gl.useProgram(depthProgram); | |
| gl.colorMask(false, false, false, false); | |
| gl.uniformMatrix4fv(d_uLightVP, false, lightVP); | |
| const casterModels = {}; | |
| for (const node of sceneNodes) { | |
| if (node.name === 'ground') continue; | |
| if (node.hp !== undefined && node.hp <= 0 && !(node.dying > 0)) continue; | |
| let model = identity(); | |
| if (node.name === 'marker' && !node.pos) { | |
| model = rotateY(identity(), now*0.001*1.2); | |
| } else if (node.pos) { | |
| let s = node.scale; | |
| if (!Array.isArray(s)) s = [s,s,s]; | |
| let m = identity(); | |
| m[0]=s[0]; m[5]=s[1]; m[10]=s[2]; | |
| m[12] = node.pos[0]; m[13] = node.pos[1]; m[14] = node.pos[2]; | |
| model = m; | |
| } | |
| casterModels[node.name] = model; | |
| if (hideBuiltinProp(node)) continue; // [e29] | |
| drawMeshDepth(node.mesh, model); | |
| drawNodeTrims(node, model, null, true); // [e29] | |
| } | |
| for (const p of ammoPickups){ | |
| if (!p.alive) continue; | |
| let m = identity(); | |
| m[0]=p.scale; m[5]=p.scale; m[10]=p.scale; | |
| m[12]=p.pos[0]; m[13]=p.pos[1]; m[14]=p.pos[2]; | |
| drawMeshDepth(p.mesh, m); | |
| } | |
| for (const d of dogs){ | |
| if (!d.alive || !cfg.dogs) continue; | |
| let m = identity(); | |
| m[0]=d.scale; m[5]=d.scale*0.6; m[10]=d.scale*1.2; | |
| m[12]=d.pos[0]; m[13]=d.pos[1]; m[14]=d.pos[2]; | |
| _e32Dog = d; drawCharacter('dog', m, d.mesh, null, null, 0, false, true); // [e29]; [e32] which dog | |
| } | |
| if (casterModels['crate0']) { | |
| let parentM = casterModels['crate0']; | |
| let childM = translate(identity(), childMarker.local[0], childMarker.local[1], childMarker.local[2]); | |
| childM = multiply(parentM, childM); | |
| drawMeshDepth(childMarker.mesh, childM); | |
| } | |
| if (lookProp.visible) { | |
| const root = lookPropRootModel(); | |
| const auth = lookProp.authoredParts || []; | |
| for (let ai = 0; ai < auth.length; ai++) { | |
| const part = auth[ai]; | |
| const model = multiply(root, part.local || identity()); | |
| drawMeshDepth(part.mesh, model); | |
| } | |
| if (lookProp.parts && lookProp.parts.length) { | |
| for (let li = 0; li < lookProp.parts.length; li++) { | |
| const part = lookProp.parts[li]; | |
| const model = multiply(root, part.local || identity()); | |
| drawMeshDepth(part.mesh, model); | |
| } | |
| } | |
| } | |
| if (cfg.enemies && hostile.alive) { | |
| drawCharacter('hostile', hostileModel(hostile).m, hostile.mesh, null, null, 0, false, true); // [e29] | |
| } | |
| if (cfg.enemies && patrolHostile.alive) { | |
| drawCharacter('patrol', hostileModel(patrolHostile).m, patrolHostile.mesh, null, null, 0, false, true); // [e29] | |
| } | |
| let doorM = identity(); | |
| let ds = door.scale; | |
| doorM[0]=ds[0]; doorM[5]=ds[1]; doorM[10]=ds[2]; | |
| doorM[12]=door.pos[0]; doorM[13]=door.pos[1]; doorM[14]=door.pos[2]; | |
| if (door.open) doorM = rotateY(doorM, Math.PI/2); | |
| drawMeshDepth(door.mesh, doorM); | |
| for (const l of lamps){ | |
| let m = identity(); | |
| m[0]=l.postScale[0]; m[5]=l.postScale[1]; m[10]=l.postScale[2]; | |
| m[12]=l.pos[0]; m[13]=l.pos[1]; m[14]=l.pos[2]; | |
| drawMeshDepth(crateMesh, m); | |
| let bm = identity(); | |
| bm[0]=l.bulbScale[0]; bm[5]=l.bulbScale[1]; bm[10]=l.bulbScale[2]; | |
| bm[12]=l.pos[0]; bm[13]=l.pos[1]+l.postScale[1]*0.5+0.1; bm[14]=l.pos[2]; | |
| drawMeshDepth(crateMesh, bm); | |
| } | |
| drawLookWorldExtras(true); // [e29] backdrop + imported scene | |
| gl.colorMask(true, true, true, true); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, null); | |
| } | |
| gl.viewport(0,0,canvas.width,canvas.height); | |
| gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); | |
| gl.useProgram(program); | |
| gl.uniformMatrix4fv(uProj, false, proj); | |
| gl.uniformMatrix4fv(uView, false, view); | |
| gl.uniform3fv(uLightDir, lightDir); | |
| let sceneAmbient = LE.dayAmbient; | |
| if (nightMode) { | |
| const litN = lamps.filter(lampLit).length; | |
| sceneAmbient = LE.nightAmbientBase + litN * LE.nightAmbientPerLamp; | |
| gl.uniform1f(uAmbientAmt, sceneAmbient); | |
| gl.uniform1f(uDirAmt, LE.nightDiffuseBase + litN * LE.nightDiffusePerLamp); | |
| } else { | |
| gl.uniform1f(uAmbientAmt, sceneAmbient); | |
| gl.uniform1f(uDirAmt, LE.dayDiffuse); | |
| } | |
| const activeLamps = (nightMode && cfg.lightExtras) ? nearestLitLamps(2) : []; // [e29] | |
| const pointBoost = LE.lampBoost; | |
| if (activeLamps[0]) { | |
| gl.uniform3fv(uPointPos, lampBulbTip(activeLamps[0])); | |
| gl.uniform3fv(uPointColor, [LE.lampColor1[0]*pointBoost, LE.lampColor1[1]*pointBoost, LE.lampColor1[2]*pointBoost]); | |
| gl.uniform1f(uPointRadius, 18.0); | |
| } else { | |
| gl.uniform3fv(uPointPos, [0,0,0]); | |
| gl.uniform3fv(uPointColor, [0,0,0]); | |
| gl.uniform1f(uPointRadius, 0.0); | |
| } | |
| if (activeLamps[1]) { | |
| gl.uniform3fv(uPointPos2, lampBulbTip(activeLamps[1])); | |
| gl.uniform3fv(uPointColor2, [LE.lampColor2[0]*pointBoost, LE.lampColor2[1]*pointBoost, LE.lampColor2[2]*pointBoost]); | |
| gl.uniform1f(uPointRadius2, 17.0); | |
| } else { | |
| gl.uniform3fv(uPointPos2, [0,0,0]); | |
| gl.uniform3fv(uPointColor2, [0,0,0]); | |
| gl.uniform1f(uPointRadius2, 0.0); | |
| } | |
| gl.uniform1f(uAlpha, 1.0); | |
| gl.uniform3fv(uCamPos, cam.pos); | |
| const fogCol = nightMode | |
| ? [LE.nightFogBase[0] + litForSky*LE.nightFogPerLamp[0], LE.nightFogBase[1] + litForSky*LE.nightFogPerLamp[1], LE.nightFogBase[2] + litForSky*LE.nightFogPerLamp[2]] | |
| : LE.dayFog; | |
| gl.uniform3fv(uFogColor, fogCol); | |
| gl.uniform1f(uFogNear, cfg.fog ? LE.fogNear : 900.0); // [e29] fog toggle | |
| gl.uniform1f(uFogFar, cfg.fog ? LE.fogFar : 1000.0); | |
| gl.activeTexture(gl.TEXTURE1); | |
| gl.bindTexture(gl.TEXTURE_2D, albedoTex); | |
| gl.uniform1i(uAlbedo, 1); | |
| if (useShadowMap) { | |
| gl.activeTexture(gl.TEXTURE0); | |
| gl.bindTexture(gl.TEXTURE_2D, shadowTex); | |
| gl.uniform1i(uShadowMap, 0); | |
| gl.uniformMatrix4fv(uLightMVP, false, lightMVP); | |
| gl.uniform1f(uShadowBias, 0.004); | |
| gl.uniform1f(uUseShadow, 1.0); | |
| } else { | |
| gl.uniformMatrix4fv(uLightMVP, false, identity()); | |
| gl.uniform1f(uShadowBias, 0.0); | |
| gl.uniform1f(uUseShadow, 0.0); | |
| } | |
| if (LE.gridColor && cfg.grid && !levelOn()) { // [e29] grid colour from look / toggle; [e30] hidden in a loaded level | |
| gl.bindBuffer(gl.ARRAY_BUFFER, gridVBO); | |
| gl.enableVertexAttribArray(aPos); | |
| gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 12, 0); | |
| gl.disableVertexAttribArray(aNormal); | |
| if (aUV >= 0) gl.disableVertexAttribArray(aUV); | |
| gl.uniformMatrix4fv(uModel, false, identity()); | |
| gl.uniform3fv(uBaseColor, LE.gridColor); | |
| gl.uniform3fv(uSpecColor, [0.0,0.0,0.0]); | |
| gl.uniform1f(uShininess, 1.0); | |
| gl.uniform1f(uUseTex, 0.0); | |
| gl.drawArrays(gl.LINES, 0, gridLines.length/3); | |
| } | |
| if (!useShadowMap) { | |
| gl.uniform1f(uPointRadius, 0.0); | |
| gl.uniform1f(uPointRadius2, 0.0); | |
| gl.uniform3fv(uLightDir, [0.0, 1.0, 0.0]); | |
| gl.uniform1f(uAlpha, 0.38); | |
| gl.enable(gl.BLEND); | |
| gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); | |
| gl.depthMask(false); | |
| for (const node of sceneNodes) { | |
| if (!frustumCull(node) || !node.pos) continue; | |
| if (node.hp !== undefined && node.hp <= 0 && !(node.dying > 0)) continue; | |
| const s = node.scale; | |
| const sx = (Array.isArray(s) ? s[0] : s) * 1.15; | |
| const sz = (Array.isArray(s) ? s[2] : s) * 1.15; | |
| let m = identity(); | |
| m[0]=sx; m[5]=1.0; m[10]=sz; | |
| m[12] = node.pos[0]; m[13] = 0.02; m[14] = node.pos[2]; | |
| gl.uniformMatrix4fv(uModel, false, m); | |
| gl.uniform3fv(uBaseColor, [0.02,0.02,0.04]); | |
| gl.uniform3fv(uSpecColor, [0,0,0]); | |
| gl.uniform1f(uShininess, 1.0); | |
| gl.uniform1f(uUseTex, 0.0); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, shadowQuad.vbo); | |
| gl.enableVertexAttribArray(aPos); | |
| gl.enableVertexAttribArray(aNormal); | |
| gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 32, 0); | |
| gl.vertexAttribPointer(aNormal, 3, gl.FLOAT, false, 32, 12); | |
| if (aUV >= 0) { | |
| gl.enableVertexAttribArray(aUV); | |
| gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 32, 24); | |
| } | |
| gl.drawArrays(gl.TRIANGLES, 0, shadowQuad.count); | |
| } | |
| gl.depthMask(true); | |
| gl.disable(gl.BLEND); | |
| gl.uniform1f(uAlpha, 1.0); | |
| gl.uniform3fv(uLightDir, lightDir); | |
| { | |
| const al = (nightMode && cfg.lightExtras) ? nearestLitLamps(2) : []; // [e29] | |
| gl.uniform1f(uPointRadius, al[0] ? 16.0 : 0.0); | |
| gl.uniform1f(uPointRadius2, al[1] ? 15.0 : 0.0); | |
| } | |
| } | |
| const nodeModels = {}; | |
| for (const node of sceneNodes) { | |
| if (!frustumCull(node)) continue; | |
| if (node.hp !== undefined && node.hp <= 0 && !(node.dying > 0)) continue; | |
| if (hideBuiltinProp(node)) continue; // [e29] | |
| let model = identity(); | |
| if (node.name === 'marker' && !node.pos) { | |
| model = rotateY(identity(), now*0.001*1.2); | |
| } else if (node.pos) { | |
| const s = node.scale; | |
| const sx = Array.isArray(s) ? s[0] : s; | |
| const sy = Array.isArray(s) ? s[1] : s; | |
| const sz = Array.isArray(s) ? s[2] : s; | |
| let m = identity(); | |
| m[0]=sx; m[5]=sy; m[10]=sz; | |
| m[12] = node.pos[0]; m[13] = node.pos[1]; m[14] = node.pos[2]; | |
| model = m; | |
| } | |
| nodeModels[node.name] = model; | |
| gl.enableVertexAttribArray(aNormal); | |
| gl.uniform1f(uUseTex, (node.useTex && cfg.textures) ? 1.0 : 0.0); // [e29] | |
| let baseColor = node.color.slice(); | |
| const _lookFlash = !!(node.hitFlash && node.hitFlash > 0); // [e29] | |
| if (node.hitFlash && node.hitFlash > 0){ | |
| baseColor = (node.hitFlashColor && node.hitFlashColor.length === 3) | |
| ? node.hitFlashColor.slice() | |
| : [1.0, 0.6, 0.6]; | |
| node.hitFlash -= dt; | |
| } | |
| if (node.hp !== undefined && node.pos && (node.hp > 0 || node.dying > 0)){ | |
| const s = node.scale; | |
| const sx0 = Array.isArray(s) ? s[0] : s; | |
| const sy0 = Array.isArray(s) ? s[1] : s; | |
| const sz0 = Array.isArray(s) ? s[2] : s; | |
| const maxHp = node.maxHp || 3; | |
| const hpShow = Math.max(node.hp, 0); | |
| let dmg = 1.0 - (maxHp - hpShow) * 0.08; | |
| if (dmg < 0.7) dmg = 0.7; | |
| let fall = 1.0; | |
| if (node.dying > 0) { | |
| fall = Math.max(0, Math.min(1, node.dying / 0.55)); | |
| dmg = 0.7; | |
| } | |
| const sx = sx0 * dmg * (0.55 + 0.45 * fall); | |
| const sy = sy0 * dmg * fall; | |
| const sz = sz0 * dmg * (0.55 + 0.45 * fall); | |
| model[0]=Math.max(0.02,sx); model[5]=Math.max(0.02,sy); model[10]=Math.max(0.02,sz); | |
| model[12]=node.pos[0]; model[14]=node.pos[2]; | |
| if (node.pivot === 'base') { | |
| model[13]=node.pos[1]; | |
| } else { | |
| model[13]= model[5] * 0.5; | |
| } | |
| } | |
| gl.uniformMatrix4fv(uModel, false, model); | |
| drawMesh(node.mesh, model, baseColor, node.spec || [0.2,0.2,0.2], node.shin || 32); | |
| if (node.pos) drawNodeTrims(node, model, _lookFlash ? baseColor : null, false); // [e29] | |
| } | |
| for (const p of ammoPickups){ | |
| if (!p.alive) continue; | |
| drawAmmoBox(p); | |
| } | |
| for (const d of dogs){ | |
| if (!d.alive || !cfg.dogs) continue; | |
| let m = identity(); | |
| m[0]=d.scale; m[5]=d.scale*0.6; m[10]=d.scale*1.2; | |
| m[12]=d.pos[0]; m[13]=d.pos[1]; m[14]=d.pos[2]; | |
| gl.uniform1f(uUseTex, 0.0); | |
| let col = d.color.slice(); | |
| const _dogFlash = d.hitFlash > 0; // [e29] | |
| if (d.hitFlash > 0) { | |
| col = (d.hitFlashColor && d.hitFlashColor.length === 3) ? d.hitFlashColor.slice() : [1.0, 0.9, 0.7]; | |
| d.hitFlash -= dt; | |
| } | |
| _e32Dog = d; drawCharacter('dog', m, d.mesh, col, d.spec, d.shin, _dogFlash, false); // [e29]; [e32] which dog | |
| } | |
| if (nodeModels['crate0']) { | |
| let parentM = nodeModels['crate0']; | |
| let childM = translate(identity(), childMarker.local[0], childMarker.local[1], childMarker.local[2]); | |
| childM = multiply(parentM, childM); | |
| gl.uniform1f(uUseTex, 0.0); | |
| drawMesh(childMarker.mesh, childM, childMarker.color, childMarker.spec, childMarker.shin); | |
| } | |
| let doorM = identity(); | |
| let ds = door.scale; | |
| doorM[0]=ds[0]; doorM[5]=ds[1]; doorM[10]=ds[2]; | |
| doorM[12]=door.pos[0]; doorM[13]=door.pos[1]; doorM[14]=door.pos[2]; | |
| if (door.open) doorM = rotateY(doorM, Math.PI/2); | |
| gl.uniform1f(uUseTex, 0.0); | |
| let doorCol = door.color; | |
| if (door.hitFlash && door.hitFlash > 0) { | |
| doorCol = (door.hitFlashColor && door.hitFlashColor.length === 3) | |
| ? door.hitFlashColor.slice() | |
| : [0.85, 0.95, 1.0]; | |
| door.hitFlash -= dt; | |
| } | |
| gl.uniformMatrix4fv(uModel, false, doorM); | |
| drawMesh(door.mesh, doorM, doorCol, door.spec, door.shin); | |
| for (const l of lamps){ | |
| const postH = l.postScale[1]; | |
| let m = identity(); | |
| m[0]=l.postScale[0]; m[5]=postH; m[10]=l.postScale[2]; | |
| m[12]=l.pos[0]; m[13]=l.pos[1] + postH * 0.5; m[14]=l.pos[2]; | |
| gl.uniform1f(uUseTex, 0.0); | |
| gl.uniformMatrix4fv(uModel, false, m); | |
| let postCol = curLook.lamps.post.slice(); // [e29] | |
| if (l.hitFlash && l.hitFlash > 0) { | |
| postCol = (l.hitFlashColor && l.hitFlashColor.length === 3) ? l.hitFlashColor.slice() : [1.0,0.95,0.7]; | |
| l.hitFlash -= dt; | |
| } | |
| drawMesh(crateMesh, m, postCol, [0.15,0.15,0.18], 18); | |
| let bm = identity(); | |
| const lit = lampLit(l); | |
| const bs = lit | |
| ? [l.bulbScale[0]*1.15, l.bulbScale[1]*1.15, l.bulbScale[2]*1.15] | |
| : [l.bulbScale[0]*0.95, l.bulbScale[1]*0.95, l.bulbScale[2]*0.95]; | |
| bm[0]=bs[0]; bm[5]=bs[1]; bm[10]=bs[2]; | |
| bm[12]=l.pos[0]; bm[13]=l.pos[1] + postH + l.bulbScale[1]*0.5; bm[14]=l.pos[2]; | |
| let bulbCol = lit ? curLook.lamps.bulbOn : (l.broken ? curLook.lamps.bulbBroken : curLook.lamps.bulbOff); // [e29] | |
| if (l.hitFlash && l.hitFlash > 0) bulbCol = [1.0, 1.0, 0.75]; | |
| const bulbSpec = lit ? [1.0,0.98,0.75] : [0.08,0.08,0.1]; | |
| const bulbShin = lit ? 120 : 12; | |
| if (lit && cfg.lightExtras) gl.uniform1f(uAmbientAmt, 1.35); // [e29] | |
| gl.uniformMatrix4fv(uModel, false, bm); | |
| drawMesh(crateMesh, bm, bulbCol, bulbSpec, bulbShin); | |
| if (lit) gl.uniform1f(uAmbientAmt, sceneAmbient); | |
| } | |
| drawLookWorldExtras(false); // [e29] backdrop + imported scene | |
| gl.uniform1f(uUseTex, 0.0); | |
| // Phase 26 look prop (textured + normal-mapped multi-mesh GLB) | |
| if (lookProp.visible) { | |
| const root = lookPropRootModel(); | |
| const auth = lookProp.authoredParts || []; | |
| for (let ai = 0; ai < auth.length; ai++) { | |
| drawTexturedPart(auth[ai], root); | |
| } | |
| if (lookProp.parts && lookProp.parts.length) { | |
| for (let li = 0; li < lookProp.parts.length; li++) { | |
| drawTexturedPart(lookProp.parts[li], root); | |
| } | |
| } | |
| gl.uniform1f(uUseTex, 0.0); | |
| } | |
| if (cfg.enemies && hostile.alive) { | |
| const hm = hostileModel(hostile); | |
| let m = hm.m; | |
| gl.uniform1f(uUseTex, 0.0); | |
| const hb = curLook.charBox.hostile; // [e29] | |
| let baseColor = hb.color.slice(); | |
| const _hFlash = !!(hm.hs.flash || hostile.hitFlash > 0); // [e29] | |
| if (hm.hs.flash) baseColor = [1.0, 0.85, 0.25]; | |
| if (hostile.hitFlash > 0) { | |
| baseColor = (hostile.hitFlashColor && hostile.hitFlashColor.length === 3) | |
| ? hostile.hitFlashColor.slice() : [1.0, 0.45, 0.4]; | |
| hostile.hitFlash -= dt; | |
| } | |
| drawCharacter('hostile', m, hostile.mesh, baseColor, hb.spec, hb.shin, _hFlash, false); // [e29] | |
| } | |
| if (cfg.enemies && patrolHostile.alive) { | |
| const hm = hostileModel(patrolHostile); | |
| let m = hm.m; | |
| gl.uniform1f(uUseTex, 0.0); | |
| const pb = curLook.charBox.patrol; // [e29] | |
| let baseColor = pb.color.slice(); | |
| const _pFlash = !!(hm.hs.flash || patrolHostile.hitFlash > 0); // [e29] | |
| if (hm.hs.flash) baseColor = [1.0, 0.9, 0.35]; | |
| if (patrolHostile.hitFlash > 0) { | |
| baseColor = (patrolHostile.hitFlashColor && patrolHostile.hitFlashColor.length === 3) | |
| ? patrolHostile.hitFlashColor.slice() : [1.0, 0.75, 0.35]; | |
| patrolHostile.hitFlash -= dt; | |
| } | |
| drawCharacter('patrol', m, patrolHostile.mesh, baseColor, pb.spec, pb.shin, _pFlash, false); // [e29] | |
| } | |
| if (cfg.fx && hitFlashTimer > 0) { // [e29] engine part: FX | |
| gl.enable(gl.BLEND); | |
| gl.blendFunc(gl.SRC_ALPHA, gl.ONE); | |
| gl.disable(gl.DEPTH_TEST); | |
| const dur = hitStyleDur > 0.01 ? hitStyleDur : 0.28; | |
| const a = Math.max(0, Math.min(1, hitFlashTimer / dur)); | |
| const s = 0.035 + 0.025 * (1.0 - a); | |
| const q = new Float32Array([ | |
| -s, -s*0.12, s, -s*0.12, s, s*0.12, -s, -s*0.12, s, s*0.12, -s, s*0.12, | |
| -s*0.12, -s, s*0.12, -s, s*0.12, s, -s*0.12, -s, s*0.12, s, -s*0.12, s | |
| ]); | |
| const vboH = gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, vboH); | |
| gl.bufferData(gl.ARRAY_BUFFER, q, gl.STATIC_DRAW); | |
| gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0); | |
| gl.enableVertexAttribArray(aPos); | |
| gl.disableVertexAttribArray(aNormal); | |
| gl.uniformMatrix4fv(uModel, false, identity()); | |
| gl.uniformMatrix4fv(uView, false, identity()); | |
| gl.uniformMatrix4fv(uProj, false, identity()); | |
| gl.uniform3fv(uBaseColor, hitStyleGl); | |
| gl.uniform1f(uAlpha, a * 0.95); | |
| gl.drawArrays(gl.TRIANGLES, 0, 12); | |
| gl.deleteBuffer(vboH); | |
| gl.enable(gl.DEPTH_TEST); | |
| gl.disable(gl.BLEND); | |
| gl.uniform1f(uAlpha, 1.0); | |
| } | |
| if (cfg.fx && muzzleFlash > 0) { // [e29] engine part: FX | |
| gl.enable(gl.BLEND); | |
| gl.blendFunc(gl.SRC_ALPHA, gl.ONE); | |
| gl.disable(gl.DEPTH_TEST); | |
| const alpha = Math.max(0, muzzleFlash / 0.12); | |
| const quad = new Float32Array([-0.08, -0.08, 0.08, -0.08, 0.08, 0.08, -0.08, -0.08, 0.08, 0.08, -0.08, 0.08]); | |
| const vbo = gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, vbo); | |
| gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW); | |
| gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0); | |
| gl.enableVertexAttribArray(aPos); | |
| gl.disableVertexAttribArray(aNormal); | |
| gl.uniformMatrix4fv(uModel, false, identity()); | |
| gl.uniformMatrix4fv(uView, false, identity()); | |
| gl.uniformMatrix4fv(uProj, false, identity()); | |
| gl.uniform3fv(uBaseColor, [1.0, 0.85, 0.4]); | |
| gl.uniform1f(uAlpha, alpha * 0.9); | |
| gl.drawArrays(gl.TRIANGLES, 0, 6); | |
| gl.deleteBuffer(vbo); | |
| gl.enable(gl.DEPTH_TEST); | |
| gl.disable(gl.BLEND); | |
| } | |
| requestAnimationFrame(render); | |
| } | |
| showCredit(2500); | |
| // ===== BEGIN GAME DATA: Breaker Bay demo/showcase camera scripts (MIT like the rest; still inline here until the engine/game split in PLAN.md moves it to game/) ===== | |
| if (/sell=p8/.test(location.search)) { | |
| setTimeout(()=>{ | |
| nightMode = false; | |
| cam.pos = [3.2, 1.55, 4.0]; | |
| cam.yaw = -0.85; | |
| cam.pitch = -0.12; | |
| cam.speed = 2.6; | |
| orbit.target = [0.15, 1.0, -2.3]; | |
| orbit.radius = 7.2; | |
| flashHint('FBO shadows'); | |
| const t0 = performance.now(); | |
| let orbitArmed = false; | |
| demoDrive = setInterval(() => { | |
| if (!demoDrive) return; | |
| const t = (performance.now() - t0) / 1000; | |
| nightMode = false; | |
| keys['KeyW'] = (t >= 0.1 && t < 2.2); | |
| keys['KeyA'] = (t >= 2.2 && t < 3.0); | |
| keys['KeyD'] = false; | |
| keys['KeyS'] = false; | |
| if (!orbit.enabled) { | |
| cam.yaw = -0.85 + Math.sin(t * 0.7) * 0.35; | |
| cam.pitch = -0.12 + Math.sin(t * 0.9) * 0.06; | |
| } | |
| if (!orbitArmed && t >= 3.0) { | |
| orbitArmed = true; | |
| orbit.target = [0.15, 1.05, -2.3]; | |
| orbit.radius = 7.0; | |
| orbit.yaw = -0.7; | |
| orbit.pitch = 0.18; | |
| enterOrbit(); | |
| } | |
| if (orbit.enabled) { | |
| orbit.yawTarget = -0.7 + (t - 3.0) * 0.55; | |
| orbit.pitchTarget = 0.18 + Math.sin((t - 3.0) * 0.8) * 0.07; | |
| orbit.radius = 7.0 + Math.sin((t - 3.0) * 0.4) * 0.8; | |
| } | |
| if (t >= 16.0) { | |
| stopDemoDrive(); | |
| if (orbit.enabled) exitOrbit(); | |
| } | |
| }, 33); | |
| }, 900); | |
| } else if (/sell=p9/.test(location.search)) { | |
| setTimeout(()=>{ | |
| nightMode = false; | |
| cam.pos = [4.0, 1.7, 11.0]; | |
| cam.yaw = -0.25; | |
| cam.pitch = 0.08; | |
| cam.speed = 2.6; | |
| orbit.target = [-3.0, 2.2, -2.0]; | |
| orbit.radius = 13.5; | |
| flashHint('sky / fog / pillars'); | |
| const t0 = performance.now(); | |
| let orbitArmed = false; | |
| demoDrive = setInterval(() => { | |
| if (!demoDrive) return; | |
| const t = (performance.now() - t0) / 1000; | |
| nightMode = false; | |
| keys['KeyW'] = (t >= 0.1 && t < 2.5); | |
| keys['KeyA'] = false; | |
| keys['KeyD'] = (t >= 2.5 && t < 3.2); | |
| keys['KeyS'] = false; | |
| if (!orbit.enabled) { | |
| cam.yaw = -0.25 + t * 0.18; | |
| cam.pitch = 0.08 + Math.sin(t * 0.6) * 0.06; | |
| } | |
| if (!orbitArmed && t >= 3.2) { | |
| orbitArmed = true; | |
| orbit.target = [-2.5, 2.3, -1.5]; | |
| orbit.radius = 13.0; | |
| orbit.yaw = 0.15; | |
| orbit.pitch = 0.08; | |
| enterOrbit(); | |
| } | |
| if (orbit.enabled) { | |
| orbit.yawTarget = 0.15 + (t - 3.2) * 0.52; | |
| orbit.pitchTarget = 0.08 + Math.sin((t - 3.2) * 0.5) * 0.10; | |
| orbit.radius = 13.0 + Math.sin((t - 3.2) * 0.3) * 1.5; | |
| } | |
| if (t >= 16.0) { | |
| stopDemoDrive(); | |
| if (orbit.enabled) exitOrbit(); | |
| } | |
| }, 33); | |
| }, 900); | |
| } else if (/demo=1/.test(location.search)) { | |
| setTimeout(()=>{ | |
| cam.pos = [0.0, 1.6, 5.0]; | |
| cam.yaw = 0.0; | |
| cam.pitch = -0.15; | |
| cam.speed = 2.4; | |
| orbit.target = [1.2, 0.8, -1.6]; | |
| const t0 = performance.now(); | |
| let orbitArmed = false; | |
| let nightToggled = false; | |
| demoDrive = setInterval(() => { | |
| if (!demoDrive) return; | |
| const t = (performance.now() - t0) / 1000; | |
| keys['KeyW'] = (t >= 0.3 && t < 3.2); | |
| keys['KeyS'] = (t >= 6.5 && t < 7.5); | |
| keys['KeyA'] = (t >= 3.3 && t < 5.2); | |
| keys['KeyD'] = (t >= 5.3 && t < 6.5); | |
| if (!orbit.enabled && t < 12) { | |
| cam.yaw = Math.sin(t * 0.4) * 0.55; | |
| cam.pitch = -0.15 + Math.sin(t * 0.55) * 0.08; | |
| } | |
| if (!orbitArmed && t >= 7.8) { | |
| orbitArmed = true; | |
| enterOrbit(); | |
| setTimeout(()=>{ if (demoDrive) exitOrbit(); }, 2800); | |
| } | |
| if (orbit.enabled) { | |
| orbit.yawTarget = 0.6 + (t - 7.8) * 0.55; | |
| orbit.pitchTarget = 0.32; | |
| } | |
| if (!nightToggled && t >= 3.5) { | |
| nightMode = true; | |
| nightToggled = true; | |
| flashHint('N day/night'); | |
| } | |
| if (nightToggled && t >= 8.5 && nightMode) { | |
| nightMode = false; | |
| } | |
| if (t > 4.2 && t < 4.5) tryShoot(); | |
| if (t > 5.8 && t < 6.1) tryShoot(); | |
| if (t >= 11.2) { | |
| stopDemoDrive(); | |
| if (orbit.enabled) exitOrbit(); | |
| } | |
| }, 40); | |
| }, 2500); | |
| } | |
| // ===== END GAME DATA ===== | |
| requestAnimationFrame(render); | |
| })(); | |