Spaces:
Running
Running
Download engine.js from axonis22/muse-breaker-engine: direct link, hf CLI and curl.
- Browser
- Download file 103 kB
-
https://huggingface.co/spaces/axonis22/muse-breaker-engine/resolve/main/engine.js
- Command line
-
hf download hf://spaces/axonis22/muse-breaker-engine/engine.js
-
curl -L -o engine.js https://huggingface.co/spaces/axonis22/muse-breaker-engine/resolve/main/engine.js
103 kB
| // Muse Breaker Engine - Phase 24 | |
| // Muse Glimmer - own engine | |
| // Raw WebGL1, no libraries | |
| // Phase 24: MitshPlays playtest polish — more ammo, more red shoot boxes, dimmer moonlight | |
| // KEEP Phase 23 lean GLB/glTF binary mesh loader (Key L) + Phase 2-23 + all coach fixes live | |
| // Phase 21.1 + coach: ammo-in-box pickups; night bulbs self-glow; nearest+farthest lamp pair | |
| // coach: per-axis AABB + pier walkable + stay-put recover | |
| (() => { | |
| const canvas = document.getElementById('muse-canvas'); | |
| const hud = document.getElementById('hud'); | |
| if (hud) hud.textContent = 'Muse Glimmer · own engine'; | |
| const creditEl = document.getElementById('credit'); | |
| const gl = canvas.getContext('webgl', { antialias: true }) || canvas.getContext('experimental-webgl'); | |
| if (!gl) { | |
| document.body.innerHTML = '<pre>WebGL not supported</pre>'; | |
| return; | |
| } | |
| function resize() { | |
| const dpr = Math.min(window.devicePixelRatio || 1, 2); | |
| const w = Math.floor(canvas.clientWidth * dpr); | |
| const h = Math.floor(canvas.clientHeight * dpr); | |
| if (canvas.width !== w || canvas.height !== h) { | |
| canvas.width = w; | |
| canvas.height = h; | |
| gl.viewport(0, 0, w, h); | |
| } | |
| } | |
| window.addEventListener('resize', resize); | |
| resize(); | |
| const vsSource = ` | |
| attribute vec3 aPos; | |
| attribute vec3 aNormal; | |
| uniform mat4 uModel; | |
| uniform mat4 uView; | |
| uniform mat4 uProj; | |
| uniform mat4 uLightMVP; | |
| varying vec3 vNormal; | |
| varying vec3 vWorldPos; | |
| varying vec4 vShadowCoord; | |
| void main(){ | |
| vec4 world = uModel * vec4(aPos, 1.0); | |
| vWorldPos = world.xyz; | |
| vNormal = mat3(uModel) * aNormal; | |
| vShadowCoord = uLightMVP * world; | |
| gl_Position = uProj * uView * world; | |
| } | |
| `; | |
| const fsSource = ` | |
| precision mediump float; | |
| varying vec3 vNormal; | |
| varying vec3 vWorldPos; | |
| varying vec4 vShadowCoord; | |
| 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; | |
| void main(){ | |
| vec3 N = normalize(vNormal); | |
| 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) { | |
| vec2 uv = vWorldPos.xz * 0.4; | |
| vec3 tex = texture2D(uAlbedo, uv).rgb; | |
| col = mix(col, col * tex, 0.9); | |
| } | |
| 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 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 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<sizeTex; y++) { | |
| for (let x=0; x<sizeTex; x++) { | |
| const i = (y*sizeTex + x)*4; | |
| const cx = Math.floor(x/tile)%2; | |
| const cy = Math.floor(y/tile)%2; | |
| const edge = ((x%tile)<2 || (y%tile)<2) ? 1 : 0; | |
| const v = (cx ^ cy) ? 210 : 95; | |
| const r = v + edge*25; | |
| const g = (v*0.85) + edge*18; | |
| const b = (v*0.55) + edge*10; | |
| d[i]=Math.min(255,r|0); d[i+1]=Math.min(255,g|0); d[i+2]=Math.min(255,b|0); d[i+3]=255; | |
| } | |
| } | |
| ctx.putImageData(imgData,0,0); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvasTex); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); | |
| function createMesh(positions, normals) { | |
| const vertCount = positions.length / 3; | |
| const data = new Float32Array(vertCount * 6); | |
| for (let i = 0; i < vertCount; i++) { | |
| data[i*6+0] = positions[i*3+0]; | |
| data[i*6+1] = positions[i*3+1]; | |
| data[i*6+2] = positions[i*3+2]; | |
| data[i*6+3] = normals[i*3+0]; | |
| data[i*6+4] = normals[i*3+1]; | |
| data[i*6+5] = normals[i*3+2]; | |
| } | |
| const vbo = gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, vbo); | |
| gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); | |
| return { vbo, count: vertCount }; | |
| } | |
| const OBJ_PYRAMID = ` | |
| v -1.2 0 -1.2 | |
| v 1.2 0 -1.2 | |
| v 1.2 0 1.2 | |
| v -1.2 0 1.2 | |
| v 0 1.8 0 | |
| f 1 2 5 | |
| f 2 3 5 | |
| f 3 4 5 | |
| f 4 1 5 | |
| f 1 4 3 | |
| f 1 3 2 | |
| `; | |
| const OBJ_ROCK = ` | |
| v -0.6 0 -0.5 | |
| v 0.6 0 -0.5 | |
| v 0.5 0 0.6 | |
| v -0.5 0 0.6 | |
| v 0 0.9 0 | |
| v -0.4 0.4 -0.3 | |
| v 0.4 0.4 -0.3 | |
| v 0.3 0.4 0.4 | |
| v -0.3 0.4 0.4 | |
| f 1 2 5 | |
| f 2 3 5 | |
| f 3 4 5 | |
| f 4 1 5 | |
| f 1 6 5 | |
| f 2 7 5 | |
| f 3 8 5 | |
| f 4 9 5 | |
| f 6 7 2 | |
| f 7 8 3 | |
| f 8 9 4 | |
| f 9 6 1 | |
| `; | |
| function parseOBJ(objText) { | |
| const verts = []; | |
| const faces = []; | |
| const lines = objText.split('\n'); | |
| for (let line of lines) { | |
| line = line.trim(); | |
| if (!line || line.startsWith('#')) continue; | |
| const p = line.split(/\s+/); | |
| if (p[0] === 'v') { | |
| verts.push(parseFloat(p[1]), parseFloat(p[2]), parseFloat(p[3])); | |
| } else if (p[0] === 'f') { | |
| const idx = p.slice(1).map(s => parseInt(s.split('/')[0],10)-1); | |
| for (let i=1;i<idx.length-1;i++) { | |
| faces.push(idx[0], idx[i], idx[i+1]); | |
| } | |
| } | |
| } | |
| const pos = []; | |
| const norm = []; | |
| for (let i=0;i<faces.length;i+=3) { | |
| const i0 = faces[i]*3, i1 = faces[i+1]*3, i2 = faces[i+2]*3; | |
| const x0=verts[i0], y0=verts[i0+1], z0=verts[i0+2]; | |
| const x1=verts[i1], y1=verts[i1+1], z1=verts[i1+2]; | |
| const x2=verts[i2], y2=verts[i2+1], z2=verts[i2+2]; | |
| pos.push(x0,y0,z0, x1,y1,z1, x2,y2,z2); | |
| const ux = x1-x0, uy = y1-y0, uz = z1-z0; | |
| const vx = x2-x0, vy = y2-y0, vz = z2-z0; | |
| let nx = uy*vz - uz*vy; | |
| let ny = uz*vx - ux*vz; | |
| let nz = ux*vy - uy*vx; | |
| const len = Math.hypot(nx,ny,nz) || 1; | |
| nx/=len; ny/=len; nz/=len; | |
| norm.push(nx,ny,nz, nx,ny,nz, nx,ny,nz); | |
| } | |
| return createMesh(new Float32Array(pos), new Float32Array(norm)); | |
| } | |
| const objMesh = parseOBJ(OBJ_PYRAMID); | |
| const objRock = parseOBJ(OBJ_ROCK); | |
| function meshBoundsFromPositions(positions) { | |
| let minX=Infinity,minY=Infinity,minZ=Infinity,maxX=-Infinity,maxY=-Infinity,maxZ=-Infinity; | |
| for (let i = 0; i < positions.length; i += 3) { | |
| const x=positions[i], y=positions[i+1], z=positions[i+2]; | |
| if (x<minX)minX=x; if (y<minY)minY=y; if (z<minZ)minZ=z; | |
| if (x>maxX)maxX=x; if (y>maxY)maxY=y; if (z>maxZ)maxZ=z; | |
| } | |
| return { min:[minX,minY,minZ], max:[maxX,maxY,maxZ], size:[maxX-minX, maxY-minY, maxZ-minZ] }; | |
| } | |
| let glbLastBounds = { min:[0,0,0], max:[1,1,1], size:[1,1,1] }; | |
| function parseGLB(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; | |
| 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; | |
| } | |
| offset += chunkLen; | |
| } | |
| if (!json) throw new Error('No JSON chunk'); | |
| const accessors = json.accessors || []; | |
| const bufferViews = json.bufferViews || []; | |
| const meshes = json.meshes || []; | |
| if (!meshes.length) throw new Error('No meshes'); | |
| const prims = meshes[0].primitives || []; | |
| if (!prims.length) throw new Error('No 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 attrs = prim.attributes || {}; | |
| if (attrs.POSITION === undefined) throw new Error('No POSITION'); | |
| function readAccessor(accIndex, asIndex) { | |
| const acc = accessors[accIndex]; | |
| if (!acc) throw new Error('Missing accessor'); | |
| const bv = bufferViews[acc.bufferView]; | |
| if (!bv) 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 = binStart + (bv.byteOffset || 0) + (acc.byteOffset || 0); | |
| const stride = bv.byteStride || (comps * typeSize); | |
| const out = asIndex ? new Uint32Array(count) : new Float32Array(count * comps); | |
| 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 = dv.getInt8(o); break; | |
| case 5121: v = dv.getUint8(o); break; | |
| case 5122: v = dv.getInt16(o, true); break; | |
| case 5123: v = dv.getUint16(o, true); break; | |
| case 5125: v = dv.getUint32(o, true); break; | |
| case 5126: v = dv.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 }; | |
| } | |
| const posAcc = readAccessor(attrs.POSITION, false); | |
| let normAcc = (attrs.NORMAL !== undefined) ? readAccessor(attrs.NORMAL, false) : null; | |
| let idx = (prim.indices !== undefined) ? readAccessor(prim.indices, true).data : null; | |
| let posOut, normOut; | |
| if (idx) { | |
| const nTri = idx.length; | |
| posOut = new Float32Array(nTri * 3); | |
| normOut = new Float32Array(nTri * 3); | |
| 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]; | |
| } | |
| } | |
| } else { | |
| posOut = posAcc.data; | |
| normOut = normAcc ? normAcc.data : new Float32Array(posAcc.count * 3); | |
| } | |
| 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; | |
| } | |
| } | |
| } | |
| glbLastBounds = meshBoundsFromPositions(posOut); | |
| if (posAcc.min && posAcc.max && posAcc.min.length >= 3) { | |
| const mn = posAcc.min, mx = 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(posOut, normOut); | |
| } | |
| 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; | |
| const GRAVITY = 22; | |
| const JUMP_VEL = 7.6; | |
| const CLIMB_SPEED = 2.6; | |
| // Mitch parkour: jump-on blocks (no climb-through ladder) | |
| const parkourBlocks = [ | |
| { pos:[6.4, 0.35, -0.4], scale:[1.1, 0.7, 1.1], color:[0.72,0.42,0.22] }, | |
| { pos:[5.1, 0.85, -0.2], scale:[1.1, 0.7, 1.1], color:[0.75,0.48,0.25] }, | |
| { pos:[3.8, 1.45, -0.35], scale:[1.15, 0.7, 1.15], color:[0.7,0.4,0.2] }, | |
| { pos:[2.5, 2.15, -0.5], scale:[1.2, 0.7, 1.2], color:[0.68,0.38,0.2] }, | |
| { pos:[1.2, 2.9, -0.4], scale:[1.4, 0.55, 1.4], color:[0.35,0.55,0.78] }, | |
| { pos:[-0.3, 3.55, -0.55], scale:[1.5, 0.5, 1.5], color:[0.3,0.7,0.45] } | |
| ]; | |
| 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, | |
| 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, | |
| baseY: 0.0 | |
| }; | |
| 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 }, | |
| { mesh: crateMesh, pos: [1.8, 0.25, -7.2], scale: 0.85, color:[0.6,0.5,0.4], spec:[0.3,0.25,0.2], shin:24, wanderTimer:0, speed:0.9, 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] }, | |
| { mesh: crateMesh, pos: [-5.0, 0.35, 0.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:'ammoPick3', accent:[0.95,0.45,0.85] }, | |
| { mesh: crateMesh, pos: [0.2, 0.35, -4.6], scale: 0.75, color: [0.42,0.28,0.16], spec:[0.25,0.2,0.15], shin:28, alive:true, amount:10, name:'ammoPick4', accent:[0.55,0.75,1.0] }, | |
| { mesh: crateMesh, pos: [6.0, 0.35, -5.5], scale: 0.75, color: [0.42,0.28,0.16], spec:[0.25,0.2,0.15], shin:28, alive:true, amount:12, name:'ammoPick5', accent:[1.0,0.55,0.25] } | |
| ]; | |
| 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, [0.48,0.32,0.18], [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 }, | |
| { name:'lamp3', pos:[-4.0,0.0,-6.0], postScale:[0.14,1.7,0.14], bulbScale:[0.24,0.24,0.24], warm:true, on:true, broken:false } | |
| ]; | |
| 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){ | |
| 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; | |
| flashHint(nightMode ? ('NIGHT — ' + lamps.filter(l=>l.on && !l.broken).length + ' LAMPS') : 'DAY — LAMPS OFF'); | |
| } | |
| window.addEventListener('keydown', e=>{ | |
| if (['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) { | |
| e.preventDefault(); | |
| stopDemoDrive(); | |
| } | |
| keys[e.code]=true; | |
| if (e.code === 'Space' && onGround && !crouching) { | |
| velY = JUMP_VEL; | |
| onGround = false; | |
| try { beep(420, 0.05, 0.04); } catch (_) {} | |
| flashHint('JUMP'); | |
| } | |
| 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(); | |
| openGlbPicker(); | |
| } | |
| 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)=>{ | |
| 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; | |
| // horizontal move always (diagonals already normalized) | |
| const nx = cam.pos[0] + (fwd[0]*move + right[0]*strafe) * cam.speed * dt; | |
| const nz = cam.pos[2] + (fwd[2]*move + right[2]*strafe) * cam.speed * dt; | |
| cam.pos[0] = nx; | |
| cam.pos[2] = nz; | |
| function platformFloor(px, pz, fromY) { | |
| let best = 0.0; | |
| for (let i = 0; i < parkourBlocks.length; i++) { | |
| const pl = parkourBlocks[i]; | |
| const top = pl.pos[1] + pl.scale[1] * 0.5; | |
| const hx = pl.scale[0] * 0.5 + 0.05; | |
| const hz = pl.scale[2] * 0.5 + 0.05; | |
| if (Math.abs(px - pl.pos[0]) <= hx && Math.abs(pz - pl.pos[2]) <= hz) { | |
| // only land if we are above / near this top (not under it) | |
| if (fromY + 0.35 >= top && top > best) best = top; | |
| } | |
| } | |
| return best; | |
| } | |
| 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.12) { | |
| airY = floorNow; | |
| velY = 0; | |
| onGround = true; | |
| } else if (airY <= 0) { | |
| airY = 0; | |
| velY = 0; | |
| onGround = true; | |
| } else { | |
| onGround = false; | |
| } | |
| } else { | |
| airY = floorProbe; | |
| velY = 0; | |
| onGround = true; | |
| } | |
| onLadder = false; | |
| cam.pos[1] = eye + airY; | |
| } | |
| function drawMesh(mesh, modelMat, baseColor, specColor, shininess){ | |
| gl.bindBuffer(gl.ARRAY_BUFFER, mesh.vbo); | |
| gl.enableVertexAttribArray(aPos); | |
| gl.enableVertexAttribArray(aNormal); | |
| gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 24, 0); | |
| gl.vertexAttribPointer(aNormal, 3, gl.FLOAT, false, 24, 12); | |
| gl.uniformMatrix4fv(uModel, false, modelMat); | |
| gl.uniform3fv(uBaseColor, baseColor); | |
| gl.uniform3fv(uSpecColor, specColor); | |
| gl.uniform1f(uShininess, shininess); | |
| gl.drawArrays(gl.TRIANGLES, 0, mesh.count); | |
| } | |
| function drawMeshDepth(mesh, modelMat){ | |
| gl.bindBuffer(gl.ARRAY_BUFFER, mesh.vbo); | |
| gl.enableVertexAttribArray(d_aPos); | |
| gl.vertexAttribPointer(d_aPos, 3, gl.FLOAT, false, 24, 0); | |
| gl.uniformMatrix4fv(d_uModel, false, modelMat); | |
| gl.drawArrays(gl.TRIANGLES, 0, mesh.count); | |
| } | |
| 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: crateMesh, model: null, color: [0.7,0.55,0.65], spec:[0.45,0.35,0.5], shin: 40, name:'crate2', pos:[-0.8,0.5,-7.4], scale:1.0, 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: objRock, model: null, color: [0.55,0.65,0.60], spec:[0.35,0.45,0.40], shin: 48, name:'objRock', pos:[-5.8,0.0,-3.6], scale:1.2, hp:3, maxHp:3, dying:0, pivot:'base', aabbH:0.9 }, | |
| { mesh: crateMesh, model: null, color:[0.55,0.58,0.62], spec:[0.2,0.2,0.25], shin:20, name:'bayWallL_left', pos:[-6.0,0.3,-2.0], scale:[2.5,0.6,0.6] }, | |
| { mesh: crateMesh, model: null, color:[0.55,0.58,0.62], spec:[0.2,0.2,0.25], shin:20, name:'bayWallL_right', pos:[-3.0,0.3,-2.0], scale:[2.5,0.6,0.6] }, | |
| { mesh: crateMesh, model: null, color:[0.55,0.58,0.62], spec:[0.2,0.2,0.25], shin:20, name:'bayWallR', pos:[4.5,0.3,-2.0], scale:[5.0,0.6,0.6] }, | |
| { 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] }, | |
| { mesh: crateMesh, model: null, color:[0.58,0.60,0.64], spec:[0.2,0.2,0.25], shin:20, name:'warehouse', pos:[4.0,1.5,-3.0], scale:[2.0,3.0,2.0] }, | |
| { mesh: crateMesh, model: null, color:[0.58,0.60,0.64], spec:[0.2,0.2,0.25], shin:20, name:'shed', pos:[-4.0,1.0,-6.0], scale:[2.0,2.0,2.0] }, | |
| { mesh: crateMesh, model: null, color:[0.55,0.55,0.58], spec:[0.18,0.18,0.22], shin:18, name:'barrel1', pos:[1.5,0.35,-4.8], scale:[0.5,0.7,0.5], hp:2, maxHp:2, dying:0, pivot:'center' }, | |
| { mesh: crateMesh, model: null, color:[0.55,0.55,0.58], spec:[0.18,0.18,0.22], shin:18, name:'barrel2', pos:[-2.8,0.35,-5.2], scale:[0.5,0.7,0.5], hp:2, maxHp:2, dying:0, pivot:'center' }, | |
| { mesh: crateMesh, model: null, color:[0.60,0.62,0.65], spec:[0.20,0.20,0.25], shin:20, name:'crateStackA', pos:[3.5,0.5,-4.0], scale:[0.8,1.0,0.8], hp:3, maxHp:3, dying:0, pivot:'center' }, | |
| { mesh: crateMesh, model: null, color:[0.60,0.62,0.65], spec:[0.20,0.20,0.25], shin:20, name:'crateStackB', pos:[-1.2,0.5,-6.5], scale:[0.8,1.0,0.8], hp:3, maxHp:3, dying:0, pivot:'center' }, | |
| { mesh: crateMesh, model: null, color:[0.52,0.48,0.44], spec:[0.15,0.14,0.12], shin:16, name:'ropeBox1', pos:[0.8,0.15,-2.5], scale:[1.2,0.3,0.3] }, | |
| { mesh: crateMesh, model: null, color:[0.52,0.48,0.44], spec:[0.15,0.14,0.12], shin:16, name:'ropeBox2', pos:[-3.8,0.15,-3.0], scale:[1.2,0.3,0.3] }, | |
| { 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.85,0.2,0.15], spec:[0.5,0.18,0.12], shin:26, name:'redBox2', pos:[5.4,0.4,-2.6], scale:0.8, hp:2, maxHp:2, dying:0, pivot:'center' }, | |
| { mesh: crateMesh, model: null, color:[0.92,0.14,0.12], spec:[0.6,0.22,0.16], shin:30, name:'redBox3', pos:[-0.4,0.4,-5.0], scale:0.95, hp:3, maxHp:3, dying:0, pivot:'center' }, | |
| { mesh: crateMesh, model: null, color:[0.86,0.17,0.13], spec:[0.52,0.18,0.14], shin:28, name:'redBox4', pos:[3.0,0.4,-7.0], scale:0.85, hp:2, maxHp:2, dying:0, pivot:'center' }, | |
| { mesh: crateMesh, model: null, color:[0.9,0.15,0.12], spec:[0.55,0.2,0.15], shin:28, name:'redBox5', pos:[-6.2,0.4,-1.2], scale:0.8, 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:[6.4,0.35,-0.4], scale:[1.1,0.7,1.1], 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:[5.1,0.85,-0.2], scale:[1.1,0.7,1.1], walkable:true }, | |
| { mesh: crateMesh, model: null, color:[0.7,0.4,0.2], spec:[0.3,0.2,0.12], shin:20, name:'parkour2', pos:[3.8,1.45,-0.35], scale:[1.15,0.7,1.15], walkable:true }, | |
| { mesh: crateMesh, model: null, color:[0.68,0.38,0.2], spec:[0.3,0.2,0.12], shin:20, name:'parkour3', pos:[2.5,2.15,-0.5], scale:[1.2,0.7,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:'parkour4', pos:[1.2,2.9,-0.4], scale:[1.4,0.55,1.4], walkable:true }, | |
| { mesh: crateMesh, model: null, color:[0.3,0.7,0.45], spec:[0.25,0.4,0.3], shin:24, name:'parkour5', pos:[-0.3,3.55,-0.55], scale:[1.5,0.5,1.5], 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 }; | |
| 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(){ | |
| const r = 0.35; | |
| const halfY = crouching ? 0.55 : 0.9; | |
| for (const node of sceneNodes){ | |
| if (!node.pos) continue; | |
| if (node.name === 'pier' || node.climb || node.walkable) continue; | |
| if (node.hp !== undefined && node.hp <= 0 && !(node.dying > 0)) continue; | |
| 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; | |
| const minX = node.pos[0] - halfX, maxX = node.pos[0] + halfX; | |
| const minZ = node.pos[2] - halfZ, maxZ = node.pos[2] + halfZ; | |
| if (cam.pos[0] <= minX || cam.pos[0] >= maxX || cam.pos[2] <= minZ || cam.pos[2] >= maxZ) continue; | |
| 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); | |
| 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; | |
| } | |
| if (!door.open) { | |
| const s = door.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; | |
| const minX = door.pos[0] - halfX, maxX = door.pos[0] + halfX; | |
| const minZ = door.pos[2] - halfZ, maxZ = door.pos[2] + halfZ; | |
| if (!(cam.pos[0] <= minX || cam.pos[0] >= maxX || cam.pos[2] <= minZ || cam.pos[2] >= maxZ)) { | |
| 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); | |
| 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; | |
| } | |
| } | |
| } | |
| 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 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(){ | |
| try { | |
| const mesh = parseGLB(reader.result); | |
| applyGlbImport(mesh); | |
| flashHint('GLB LOADED'); | |
| } catch (err) { | |
| console.error(err); | |
| 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 (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; | |
| } | |
| ammoMag -= 1; | |
| 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 (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 (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) 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 = 0.55; | |
| 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 = 0.55; | |
| 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 = 0.45; | |
| } | |
| 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 hostileDrawScale(h){ | |
| let dmg = 1.0 - Math.max(0, 5 - Math.max(h.hp, 0)) * 0.05; | |
| let fall = 1.0; | |
| if (h.dying > 0) { | |
| const t = Math.max(0, Math.min(1, h.dying / 0.55)); | |
| fall = t; | |
| } | |
| const xz = h.scale * dmg * (0.55 + 0.45 * fall); | |
| const y = h.scale * dmg * fall; | |
| return { xz: Math.max(0.02, xz), y: Math.max(0.02, y) }; | |
| } | |
| function hostileSkipsNode(node){ | |
| if (!node || !node.name) return true; | |
| if (node.name === 'pier' || node.name === 'ground') return true; | |
| if (node.name.indexOf('bayWall') === 0) return true; | |
| if (node.name.indexOf('ropeBox') === 0) return true; | |
| return false; | |
| } | |
| function pushOutOfNodes(pos, bodyHalf){ | |
| for (const node of sceneNodes){ | |
| if (!node.pos) continue; | |
| if (hostileSkipsNode(node)) continue; | |
| if (node.hp !== undefined && node.hp <= 0 && !(node.dying > 0)) continue; | |
| const s = node.scale; | |
| const sx = Array.isArray(s) ? s[0] : s; | |
| const sz = Array.isArray(s) ? s[2] : s; | |
| const h2x = sx * 0.5 + bodyHalf, h2z = sz * 0.5 + bodyHalf; | |
| const minX = node.pos[0] - h2x, maxX = node.pos[0] + h2x; | |
| const minZ = node.pos[2] - h2z, maxZ = node.pos[2] + h2z; | |
| 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; | |
| } | |
| } | |
| function updateHostile(dt){ | |
| if (!hostile.alive) return; | |
| if (hostile.dying > 0) { | |
| hostile.dying -= dt; | |
| if (hostile.dying <= 0) hostile.alive = false; | |
| 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.35; | |
| const half = hostile.scale * 0.5 + r; | |
| if (!hostile.alerted) { | |
| if (distXZ <= (hostile.aggroRange || 7)) { | |
| hostile.alerted = true; | |
| flashHint('HOSTILE'); | |
| } else { | |
| return; | |
| } | |
| } else if (distXZ > (hostile.loseRange || 14)) { | |
| hostile.alerted = false; | |
| 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; | |
| let ox = hostile.pos[0], oz = hostile.pos[2]; | |
| hostile.pos[0] = tryX; hostile.pos[2] = tryZ; | |
| pushOutOfNodes(hostile.pos, half); | |
| if (Math.hypot(hostile.pos[0]-ox, hostile.pos[2]-oz) < step * 0.15) { | |
| hostile.pos[0] = ox; hostile.pos[2] = oz; | |
| hostile.pos[0] = tryX; | |
| pushOutOfNodes(hostile.pos, half); | |
| const xMoved = Math.abs(hostile.pos[0] - ox); | |
| hostile.pos[0] = ox; hostile.pos[2] = oz; | |
| hostile.pos[2] = tryZ; | |
| pushOutOfNodes(hostile.pos, half); | |
| const zMoved = Math.abs(hostile.pos[2] - oz); | |
| if (xMoved >= zMoved) { | |
| hostile.pos[0] = ox; hostile.pos[2] = oz; | |
| hostile.pos[0] = tryX; | |
| pushOutOfNodes(hostile.pos, half); | |
| if (zMoved > 0.001) { | |
| const keepX = hostile.pos[0]; | |
| hostile.pos[2] = tryZ; | |
| pushOutOfNodes(hostile.pos, half); | |
| hostile.pos[0] = keepX; | |
| } | |
| } else { | |
| if (xMoved > 0.001) { | |
| const keepZ = hostile.pos[2]; | |
| hostile.pos[0] = tryX; | |
| pushOutOfNodes(hostile.pos, half); | |
| hostile.pos[2] = keepZ; | |
| } | |
| } | |
| } | |
| } | |
| separateFromDogs(hostile.pos, hostile.scale * 0.55); | |
| if (hurtCooldown > 0) hurtCooldown -= dt; | |
| const d2 = Math.hypot(hostile.pos[0] - px, hostile.pos[2] - pz); | |
| if (d2 < 1.2 && hurtCooldown <= 0) { | |
| playerHP -= 1; | |
| hurtCooldown = 0.6; | |
| hurtFlash = 0.25; | |
| flashHint('HURT'); sfxHurt(); | |
| if (playerHP <= 0) { | |
| 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; | |
| flashHint('RECOVERED'); | |
| } | |
| } | |
| } | |
| 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 (patrolHostile.dying > 0) { | |
| patrolHostile.dying -= dt; | |
| if (patrolHostile.dying <= 0) patrolHostile.alive = false; | |
| return; | |
| } | |
| 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; | |
| patrolHostile.pos[0] += nx * patrolHostile.speed * dt; | |
| patrolHostile.pos[2] += nz * patrolHostile.speed * dt; | |
| } else { | |
| patrolHostile.targetIsA = !patrolHostile.targetIsA; | |
| } | |
| const pBeforeX = patrolHostile.pos[0], pBeforeZ = patrolHostile.pos[2]; | |
| separateFromDogs(patrolHostile.pos, patrolHostile.scale * 0.6); | |
| 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 (d2 < 1.2 && hurtCooldown <= 0) { | |
| playerHP -= 1; | |
| hurtCooldown = 0.6; | |
| hurtFlash = 0.25; | |
| flashHint('HURT'); sfxHurt(); | |
| if (playerHP <= 0) { | |
| 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; | |
| flashHint('RECOVERED'); | |
| } | |
| } | |
| } | |
| 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 = '<div style="position:absolute;left:8px;top:0;width:2px;height:18px;background:rgba(220,240,255,.75)"></div><div style="position:absolute;left:0;top:8px;width:18px;height:2px;background:rgba(220,240,255,.75)"></div>'; | |
| document.body.appendChild(crossEl); | |
| const hitMarkEl = document.createElement('div'); | |
| hitMarkEl.id = 'muse-hitmark'; | |
| hitMarkEl.style.cssText = 'position:fixed;left:50%;top:50%;width:28px;height:28px;margin:-14px 0 0 -14px;pointer-events:none;z-index:21;opacity:0;transition:opacity .08s;color:#ffe566;font-size:22px;font-weight:700;text-align:center;line-height:28px;text-shadow:0 0 8px #ffaa00,0 0 2px #000'; | |
| hitMarkEl.textContent = 'X'; | |
| document.body.appendChild(hitMarkEl); | |
| const HIT_STYLES = { | |
| hostile: { mark:'+', css:'#ff4d4d', gl:[1.0,0.25,0.22], flash:[1.0,0.45,0.4], dur:0.34, hint:'HIT HOSTILE' }, | |
| patrol: { mark:'+', css:'#ff9a3c', gl:[1.0,0.55,0.15], flash:[1.0,0.75,0.35], dur:0.34, hint:'HIT PATROL' }, | |
| dog: { mark:'*', css:'#ffe08a', gl:[1.0,0.88,0.45], flash:[1.0,0.9,0.7], dur:0.3, hint:'DOG!' }, | |
| wood: { mark:'#', css:'#c48a4a', gl:[0.85,0.55,0.28], flash:[0.95,0.7,0.4], dur:0.28, hint:'WOOD HIT' }, | |
| metal: { mark:'+', css:'#d8f0ff', gl:[0.75,0.9,1.0], flash:[0.85,0.95,1.0], dur:0.26, hint:'METAL HIT' }, | |
| wall: { mark:'x', css:'#a8b4c4', gl:[0.7,0.78,0.88], flash:[0.85,0.88,0.92], dur:0.26, hint:'WALL HIT' }, | |
| stone: { mark:'*', css:'#d2c4a0', gl:[0.85,0.78,0.55], flash:[0.9,0.85,0.7], dur:0.3, hint:'STONE HIT' }, | |
| soft: { mark:'X', css:'#ffe566', gl:[1.0,0.92,0.25], flash:[1.0,0.75,0.45], dur:0.28, hint:'TARGET HIT' }, | |
| lamp: { mark:'*', css:'#fff6b0', gl:[1.0,0.95,0.55], flash:[1.0,1.0,0.85], dur:0.45, hint:'LAMP HIT', burst:true } | |
| }; | |
| function resolveHitStyle(kind, node){ | |
| if (kind === 'hostile') return HIT_STYLES.hostile; | |
| if (kind === 'patrol') return HIT_STYLES.patrol; | |
| if (kind === 'dog') return HIT_STYLES.dog; | |
| if (kind === 'lamp') return HIT_STYLES.lamp; | |
| const name = (node && node.name) ? String(node.name) : ''; | |
| if (/barrel|door/i.test(name)) return HIT_STYLES.metal; | |
| if (/bayWall|warehouse|shed|pier/i.test(name)) return HIT_STYLES.wall; | |
| if (/cover|crate|rope|Stack/i.test(name)) return HIT_STYLES.wood; | |
| if (/glb|glbImport/i.test(name)) return HIT_STYLES.stone; | |
| if (/Pyramid|Rock|obj/i.test(name)) return HIT_STYLES.stone; | |
| if (kind === 'impact') { | |
| if (/Wall|warehouse|shed/i.test(name)) return HIT_STYLES.wall; | |
| if (/cover|rope/i.test(name)) return HIT_STYLES.wood; | |
| return HIT_STYLES.wall; | |
| } | |
| return HIT_STYLES.soft; | |
| } | |
| function showHitMarker(style){ | |
| 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(); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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(); | |
| } | |
| } | |
| updateCamera(dt); | |
| if (!orbit.enabled) { | |
| resolveCollision(); | |
| checkAmmoPickups(); | |
| updateHostile(dt); | |
| updatePatrolHostile(dt); | |
| updateDogs(dt); | |
| 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 lightDirDay = [0.5, 0.8, 0.6]; | |
| const lightDirNight = [0.25, 0.95, 0.35]; | |
| 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 = [0.45,0.58,0.78]; | |
| const litForSky = nightMode ? lamps.filter(lampLit).length : 4; | |
| const nightClear = [ | |
| 0.005 + litForSky * 0.004, | |
| 0.007 + litForSky * 0.005, | |
| 0.020 + litForSky * 0.008 | |
| ]; | |
| 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; | |
| drawMeshDepth(node.mesh, model); | |
| } | |
| 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) 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]; | |
| drawMeshDepth(d.mesh, m); | |
| } | |
| 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 (hostile.alive) { | |
| const hs = hostileDrawScale(hostile); | |
| let m = groundedBoxModel(hostile.pos[0], hostile.pos[2], hs.xz, hs.y, hostile.baseY); | |
| drawMeshDepth(hostile.mesh, m); | |
| } | |
| if (patrolHostile.alive) { | |
| const hs = hostileDrawScale(patrolHostile); | |
| let m = groundedBoxModel(patrolHostile.pos[0], patrolHostile.pos[2], hs.xz, hs.y, patrolHostile.baseY); | |
| drawMeshDepth(patrolHostile.mesh, m); | |
| } | |
| 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); | |
| } | |
| 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 = 0.25; | |
| if (nightMode) { | |
| const litN = lamps.filter(lampLit).length; | |
| sceneAmbient = 0.022 + litN * 0.008; | |
| gl.uniform1f(uAmbientAmt, sceneAmbient); | |
| gl.uniform1f(uDirAmt, 0.045 + litN * 0.015); | |
| } else { | |
| gl.uniform1f(uAmbientAmt, sceneAmbient); | |
| gl.uniform1f(uDirAmt, 0.75); | |
| } | |
| const activeLamps = nightMode ? nearestLitLamps(2) : []; | |
| const pointBoost = 2.75; | |
| if (activeLamps[0]) { | |
| gl.uniform3fv(uPointPos, lampBulbTip(activeLamps[0])); | |
| gl.uniform3fv(uPointColor, [1.0*pointBoost, 0.88*pointBoost, 0.48*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, [0.95*pointBoost, 0.82*pointBoost, 0.42*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 | |
| ? [0.01 + litForSky*0.004, 0.015 + litForSky*0.005, 0.04 + litForSky*0.01] | |
| : [0.45,0.58,0.78]; | |
| gl.uniform3fv(uFogColor, fogCol); | |
| gl.uniform1f(uFogNear, 8.0); | |
| gl.uniform1f(uFogFar, 28.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); | |
| } | |
| gl.bindBuffer(gl.ARRAY_BUFFER, gridVBO); | |
| gl.enableVertexAttribArray(aPos); | |
| gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 12, 0); | |
| gl.disableVertexAttribArray(aNormal); | |
| gl.uniformMatrix4fv(uModel, false, identity()); | |
| gl.uniform3fv(uBaseColor, [0.75,0.85,1.0]); | |
| 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, 24, 0); | |
| gl.vertexAttribPointer(aNormal, 3, gl.FLOAT, false, 24, 12); | |
| 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 ? nearestLitLamps(2) : []; | |
| 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; | |
| 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 ? 1.0 : 0.0); | |
| let baseColor = node.color.slice(); | |
| 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.45)); | |
| 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); | |
| } | |
| for (const p of ammoPickups){ | |
| if (!p.alive) continue; | |
| drawAmmoBox(p); | |
| } | |
| for (const d of dogs){ | |
| if (!d.alive) 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(); | |
| if (d.hitFlash > 0) { | |
| col = (d.hitFlashColor && d.hitFlashColor.length === 3) ? d.hitFlashColor.slice() : [1.0, 0.9, 0.7]; | |
| d.hitFlash -= dt; | |
| } | |
| gl.uniformMatrix4fv(uModel, false, m); | |
| drawMesh(d.mesh, m, col, d.spec, d.shin); | |
| } | |
| 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 = [0.35,0.35,0.38]; | |
| 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 ? [1.0,0.92,0.45] : (l.broken ? [0.12,0.12,0.13] : [0.22,0.22,0.24]); | |
| 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) gl.uniform1f(uAmbientAmt, 1.35); | |
| gl.uniformMatrix4fv(uModel, false, bm); | |
| drawMesh(crateMesh, bm, bulbCol, bulbSpec, bulbShin); | |
| if (lit) gl.uniform1f(uAmbientAmt, sceneAmbient); | |
| } | |
| if (hostile.alive) { | |
| const hs = hostileDrawScale(hostile); | |
| let m = groundedBoxModel(hostile.pos[0], hostile.pos[2], hs.xz, hs.y, hostile.baseY); | |
| gl.uniform1f(uUseTex, 0.0); | |
| let baseColor = [0.9, 0.2, 0.2]; | |
| if (hostile.hitFlash > 0) { | |
| baseColor = (hostile.hitFlashColor && hostile.hitFlashColor.length === 3) | |
| ? hostile.hitFlashColor.slice() : [1.0, 0.45, 0.4]; | |
| hostile.hitFlash -= dt; | |
| } | |
| gl.uniformMatrix4fv(uModel, false, m); | |
| drawMesh(hostile.mesh, m, baseColor, [0.6,0.2,0.2], 40); | |
| } | |
| if (patrolHostile.alive) { | |
| const hs = hostileDrawScale(patrolHostile); | |
| let m = groundedBoxModel(patrolHostile.pos[0], patrolHostile.pos[2], hs.xz, hs.y, patrolHostile.baseY); | |
| gl.uniform1f(uUseTex, 0.0); | |
| let baseColor = [0.95, 0.65, 0.15]; | |
| if (patrolHostile.hitFlash > 0) { | |
| baseColor = (patrolHostile.hitFlashColor && patrolHostile.hitFlashColor.length === 3) | |
| ? patrolHostile.hitFlashColor.slice() : [1.0, 0.75, 0.35]; | |
| patrolHostile.hitFlash -= dt; | |
| } | |
| gl.uniformMatrix4fv(uModel, false, m); | |
| drawMesh(patrolHostile.mesh, m, baseColor, [0.7,0.4,0.1], 40); | |
| } | |
| if (hitFlashTimer > 0) { | |
| 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 (muzzleFlash > 0) { | |
| 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); | |
| 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); | |
| } | |
| requestAnimationFrame(render); | |
| })(); | |