// 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) // 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 · Phase 29 Combat Feel'; const creditEl = document.getElementById('credit'); const gl = canvas.getContext('webgl', { antialias: true }) || canvas.getContext('experimental-webgl'); if (!gl) { document.body.innerHTML = '
WebGL not supported
'; 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(); const vsSource = ` attribute vec3 aPos; attribute vec3 aNormal; attribute vec2 aUV; attribute vec4 aTangent; 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(){ vec4 world = uModel * vec4(aPos, 1.0); vWorldPos = world.xyz; mat3 nMat = mat3(uModel); vec3 N = normalize(nMat * aNormal); vNormal = N; vN = N; vec3 T = nMat * aTangent.xyz; 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; uniform mat4 uModel; uniform mat4 uLightVP; void main(){ gl_Position = uLightVP * uModel * vec4(aPos, 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; } const program = gl.createProgram(); gl.attachShader(program, compile(gl.VERTEX_SHADER, vsSource)); gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fsSource)); 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, vsDepth)); gl.attachShader(depthProgram, compile(gl.FRAGMENT_SHADER, fsDepth)); gl.linkProgram(depthProgram); 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'); 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= (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;imaxX)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 mesh; if (nm || arrays.tanOut) { const tans = arrays.tanOut || computeTangentsFromUV(arrays.posOut, arrays.normOut, arrays.uvOut); mesh = createMeshWithTangents(arrays.posOut, arrays.normOut, arrays.uvOut, tans); } else { mesh = createMesh(arrays.posOut, arrays.normOut, arrays.uvOut); } parts.push({ mesh: mesh, 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) { 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 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 = Math.max(0, Math.min(12, airY)); 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) { 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 // 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(){ 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); // also keep a untextured single-mesh fallback node for collision/shoot tests try { const mesh = parseGLB(reader.result); applyGlbImport(mesh); } catch (_) {} flashHint('GLB LOOK ' + res.parts.length); }).catch(function(err){ console.error(err); try { const mesh = parseGLB(reader.result); applyGlbImport(mesh); flashHint('GLB LOADED'); } 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; const t = rayAABB(eye, dir, box[0], box[1]); 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 = rayAABB(eye, dir, min, max); 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 = rayAABB(eye, dir, min, max); 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 = rayAABB(eye, dir, min, max); 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 }; } } } if (best){ sfxHit(); if (best.isHostile) { const st = resolveHitStyle('hostile'); showHitMarker(st); applyObjectFlash(hostile, st); hostile.hp -= 1; 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 -= 1; 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.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); } } } 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){ 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 = hostile.speed * dt; 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 = patrolHostile.speed * dt; 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]; 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 (d.pos[0] < -6) d.pos[0] = -6; if (d.pos[0] > 6) d.pos[0] = 6; if (d.pos[2] < -8) d.pos[2] = -8; if (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 = '
'; 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) { try { gl.deleteTexture(p.tex); } catch(_){} } } 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 = []; 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 }; 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; } 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 }; 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 (!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 (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 (importSlots.character && (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, 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 ===================== let _charShapeInit = false; function render(now){ if (!_charShapeInit) { _charShapeInit = true; applyCharShapePack(); } resize(); 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'); } } 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]); } 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]; for (let i=0;i<3;i++) lightPos[i] = orbit.target[i] - lightDir[i]*12; const lightView = lookAt(lightPos, orbit.target, [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]; drawCharacter('dog', m, d.mesh, null, null, 0, false, true); // [e29] } 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) { // [e29] grid colour from look / toggle 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; } drawCharacter('dog', m, d.mesh, col, d.spec, d.shin, _dogFlash, false); // [e29] } 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); })();