import * as THREE from "three"; import { FBXLoader } from "three/addons/loaders/FBXLoader.js"; import { TGALoader } from "three/addons/loaders/TGALoader.js"; import { PointerLockControls } from "three/addons/controls/PointerLockControls.js"; import * as BufferGeometryUtils from "three/addons/utils/BufferGeometryUtils.js"; import { MeshBVH, acceleratedRaycast } from "https://esm.sh/three-mesh-bvh@0.7.6?deps=three@0.160.0"; import { encode, decode } from "https://esm.sh/@msgpack/msgpack@3.1.2?bundle"; /* status overlay */ const statusEl = document.createElement("div"); Object.assign(statusEl.style, { position:"fixed", left:"12px", top:"12px", zIndex:"99999", padding:"6px 10px", borderRadius:"8px", background:"rgba(0,0,0,.55)", color:"#fff", font:"12px/1.2 system-ui,-apple-system,Segoe UI,Roboto,Inter,sans-serif", whiteSpace:"pre", pointerEvents:"none", maxWidth:"45vw" }); statusEl.textContent = "booting…"; document.body.appendChild(statusEl); const setStatus = (t)=> (statusEl.textContent = t); /* config */ const MODEL_URL = "https://world.seppjm.com/3js/models/scene.fbx"; const USE_FOG = true; let RENDER_SCALE = 3; const COLOR_LEVELS = 32, USE_DITHER = true, VERTEX_SNAP_PIXELS = 0.75; const FIRE_POS = new THREE.Vector3(2885.090654499771, 5.937671631541306, -2843.489246932181); const CAM_POS = new THREE.Vector3(3280.98691276581, 386.84586301208896, -2012.4527013816644); const CAM_ROT_DEG = { yaw: 395.51276597880525, pitch: 2.7272791048226543 }; const CAM_FOV = 90; /* fire */ const FIRE_SPRITE_SIZE = 150.0, FIRE_HEIGHT_OFFSET = 0.25; const FIRE_MAIN_INTENSITY = 2.2, FIRE_SUB_INTENSITY = 1.0, FIRE_MAIN_DISTANCE = 12000, FIRE_SUB_DISTANCE = 6000, FIRE_DECAY = 1.2; /* networking */ const WS_URL = "wss://ws.world.seppjm.com/ws"; /* scale / movement */ const SCALE = 50; const WALK_SPEED = 4.6 * SCALE, ACCEL = 30.0, AIR_ACCEL = 7.0, FRICTION = 9.0, GRAVITY = 27.0 * SCALE; const JUMP_STRENGTH = 11.0 * SCALE, COYOTE_TIME = 0.12; /* spectator freecam */ const FREECAM_SPEED = 6.0 * SCALE; /* slopes / capsule */ const WALL_NORMAL_Y = 0.58; const CAPSULE_RADIUS = 0.5 * SCALE, CAPSULE_HEIGHT = 1.2 * SCALE, PLAYER_EYE_HEIGHT = CAPSULE_HEIGHT * 0.9 + CAPSULE_RADIUS; /* spawn */ const SPAWN_OFFSET = new THREE.Vector3(2.8 * SCALE, 0, 2.2 * SCALE), SPAWN_EXTRA_Y = 50 * SCALE; /* collision tuning */ const CONTACT_OFFSET = 0.08 * SCALE, MAX_SUBSTEP_DIST = 0.25 * CAPSULE_RADIUS; /* ground + wall */ const SNAP_CAST_DIST = 3.0 * CAPSULE_RADIUS, SNAP_EPS = 0.15 * SCALE; const WALL_MARGIN = 0.16 * SCALE, WALL_ITER = 3, RAY_FAN_COUNT = 16; /* net cadence */ const SEND_RATE_MS = 1000 / 12; /* remote visuals */ const SPRITE_HEIGHT = Math.max(1.6 * SCALE, PLAYER_EYE_HEIGHT * 1.05); const SPRITE_ASPECT = 0.55; const NAME_PAD = 0.24 * SCALE; const PLAYER_SPRITE_URL = null; /* renderer + post */ const canvas = document.createElement("canvas"); canvas.id = "three-bg"; Object.assign(canvas.style, { position:"fixed", inset:"0", width:"100vw", height:"100vh", display:"block", zIndex:"-1", pointerEvents:"none", opacity:"0", transition:"opacity 600ms ease", imageRendering:"pixelated", background:"transparent" }); document.body.prepend(canvas); const renderer = new THREE.WebGLRenderer({ canvas, antialias:false, alpha:true, powerPreference:"high-performance" }); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.NoToneMapping; renderer.shadowMap.enabled = false; renderer.setPixelRatio(1); renderer.setClearColor(0x000000, 0); /* 2D labels layer */ const labelsLayer = document.createElement("div"); Object.assign(labelsLayer.style, { position:"fixed", inset:"0", pointerEvents:"none", zIndex:"-1", fontFamily:"Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" }); document.body.appendChild(labelsLayer); let lowW=0, lowH=0, rt=null; const postScene = new THREE.Scene(); const postCam = new THREE.OrthographicCamera(-1,1,1,-1,0,1); const postMat = new THREE.ShaderMaterial({ uniforms:{ tDiffuse:{value:null}, uLowRes:{value:new THREE.Vector2(1,1)}, uLevels:{value:COLOR_LEVELS}, uDither:{value:USE_DITHER?1:0}, uTime:{value:0} }, vertexShader:`varying vec2 vUv; void main(){ vUv=(position.xy+1.)*.5; gl_Position=vec4(position.xy,0.,1.); }`, fragmentShader:` precision mediump float; uniform sampler2D tDiffuse; uniform vec2 uLowRes; uniform int uLevels,uDither; uniform float uTime; varying vec2 vUv; float hash(vec2 p){ p=fract(p*vec2(123.34,345.45)); p+=dot(p,p+34.345); return fract(p.x*p.y); } vec3 quantize(vec3 c, vec2 pix){ float L=float(uLevels); float t=(uDither==1)?(hash(pix+uTime)-.5):0.; return floor(clamp(c+t/L,0.,1.)*(L-1.)+.5)/(L-1.); } void main(){ vec2 p=vUv*uLowRes; vec2 uv=(floor(p)+.5)/uLowRes; vec3 col=texture2D(tDiffuse,uv).rgb; col=quantize(col,p); gl_FragColor=vec4(col,1.); } `, depthTest:false, depthWrite:false }); postScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2,2), postMat)); function allocRT(){ const w=innerWidth,h=innerHeight; lowW=Math.max(1,Math.floor(w/RENDER_SCALE)); lowH=Math.max(1,Math.floor(h/RENDER_SCALE)); renderer.setSize(w,h,false); if(rt) rt.dispose(); rt=new THREE.WebGLRenderTarget(lowW,lowH,{minFilter:THREE.NearestFilter,magFilter:THREE.NearestFilter,depthBuffer:true,stencilBuffer:false,type:THREE.UnsignedByteType,samples:0}); postMat.uniforms.uLowRes.value.set(lowW,lowH); } allocRT(); /* scene + idle cam */ const scene = new THREE.Scene(); scene.environment = null; scene.fog = USE_FOG ? new THREE.FogExp2(0x0b1020, 0.02) : null; const camera = new THREE.PerspectiveCamera(CAM_FOV, innerWidth/innerHeight, 0.005, 5000); const BASE_YAW = THREE.MathUtils.degToRad(((CAM_ROT_DEG.yaw%360)+360)%360); const BASE_PITCH = THREE.MathUtils.degToRad(CAM_ROT_DEG.pitch); camera.position.copy(CAM_POS); camera.rotation.set(BASE_PITCH, BASE_YAW, 0, "YXZ"); camera.updateProjectionMatrix(); const MAX_YAW_DELTA = THREE.MathUtils.degToRad(6), MAX_PITCH_DELTA = THREE.MathUtils.degToRad(4); let aimYaw=BASE_YAW, aimPitch=BASE_PITCH, curYaw=BASE_YAW, curPitch=BASE_PITCH, playMode=false; addEventListener("pointermove",(e)=>{ if(playMode || spectateMode) return; const nx=(e.clientX/innerWidth)*2-1, ny=(e.clientY/innerHeight)*2-1; aimYaw=BASE_YAW - nx*MAX_YAW_DELTA; aimPitch=BASE_PITCH + ny*MAX_PITCH_DELTA; },{passive:true}); function updateCameraHover(dt){ const s=1.0 - Math.pow(0.2, dt*60); curYaw += (aimYaw-curYaw)*s; curPitch += (aimPitch-curPitch)*s; camera.rotation.set(curPitch,curYaw,0,"YXZ"); } scene.add(new THREE.HemisphereLight(0xb0c8ff,0x0b1020,0.28)); const sun=new THREE.DirectionalLight(0xffffff,0.6); sun.position.set(3,5,4); scene.add(sun); /* loader + bvh */ THREE.Mesh.prototype.raycast = acceleratedRaycast; const basePath = MODEL_URL.slice(0, MODEL_URL.lastIndexOf("/") + 1) || "/"; const manager = new THREE.LoadingManager(); manager.addHandler(/\.tga$/i, new TGALoader(manager)); const loader = new FBXLoader(manager); loader.setResourcePath(basePath); let model, mixer, levelReady=false, colliderGeom=null, colliderBVH=null, colliderMesh=null, colliderMinY=-Infinity, colliderMaxY=Infinity; const patchedMaterials = new Set(); const perFrame = []; /* shader uniform cache for resize */ const ps1Uniforms = new WeakMap(); /* patch material for PS1 look */ function ps1ifyLambert(mat){ ["map","emissiveMap","aoMap","specularMap"].forEach((k)=>{ const t=mat[k]; if(!t) return; t.generateMipmaps=false; t.minFilter=THREE.NearestFilter; t.magFilter=THREE.NearestFilter; t.anisotropy=0; t.needsUpdate=true; }); mat.depthWrite=true; mat.depthTest=true; mat.dithering=false; mat.polygonOffset=true; mat.polygonOffsetFactor=-0.5; mat.polygonOffsetUnits=1.0; mat.onBeforeCompile=(shader)=>{ shader.uniforms.uResolution = { value:new THREE.Vector2(innerWidth,innerHeight) }; shader.uniforms.uSnapPixels = { value:VERTEX_SNAP_PIXELS }; if(!/uniform\s+vec2\s+uResolution/.test(shader.vertexShader)){ shader.vertexShader = `uniform vec2 uResolution;\nuniform float uSnapPixels;\n` + shader.vertexShader; } shader.vertexShader = shader.vertexShader.replace( '#include ', ` #include vec2 ndc = gl_Position.xy / gl_Position.w; vec2 pix = (ndc*0.5 + 0.5) * uResolution; pix = floor(pix / uSnapPixels) * uSnapPixels; vec2 ndc2 = (pix / uResolution) * 2.0 - 1.0; gl_Position.xy = ndc2 * gl_Position.w; ` ); ps1Uniforms.set(mat, shader.uniforms); patchedMaterials.add(mat); }; return mat; } /* convert imported model to PS1 style and build collider */ function ps1ifyMaterialsAndBuildCollider(root){ const geos=[]; root.updateWorldMatrix(true,true); root.traverse((n)=>{ if(!n.isMesh||!n.geometry) return; const src=n.material; const mats=(Array.isArray(src)?src:[src]).filter(Boolean); const newMats=mats.map((m)=>ps1ifyLambert(new THREE.MeshLambertMaterial({ color:(m.color&&m.color.isColor)?m.color.clone():new THREE.Color(0xffffff), map:m.map||null, transparent:false, alphaTest: (m.alphaTest??0)>0?m.alphaTest: (m.map?.format===THREE.RGBAFormat?0.5:0.0), side:THREE.DoubleSide }))); n.material = Array.isArray(src)?newMats:newMats[0]; const srcGeo=n.geometry.clone(); srcGeo.applyMatrix4(n.matrixWorld); const pos=srcGeo.getAttribute("position"); if(!pos) return; const clean=new THREE.BufferGeometry(); clean.setAttribute("position",pos.clone()); clean.setIndex(null); geos.push(clean); }); let merged=null; try{ merged=BufferGeometryUtils.mergeGeometries(geos,false); } catch{ const chunks=[]; for(let i=0;i{ t+=dt; const pulse=0.45*Math.sin(t*5.4)+0.28*Math.sin(t*8.7+1.1); fireLight.intensity=FIRE_MAIN_INTENSITY + pulse; fireLight2.intensity=FIRE_SUB_INTENSITY + pulse*0.75; fireLight.color.setHSL(0.06+Math.sin(t*1.6)*0.01,1.0,0.55); fireLight2.color.setHSL(0.03+Math.sin(t*1.9+0.8)*0.012,1.0,0.47); fireSprite.material.rotation += dt*0.5; const j=1.0+Math.sin(t*6.0)*0.06; fireSprite.scale.set(FIRE_SPRITE_SIZE*j, FIRE_SPRITE_SIZE*1.35*j, 1); }); } /* load world */ loader.load(MODEL_URL, (fbx)=>{ model=fbx; scene.add(model); ps1ifyMaterialsAndBuildCollider(model); if(fbx.animations?.length){ mixer=new THREE.AnimationMixer(model); mixer.clipAction(fbx.animations[0]).play(); } autoFogFor(model); addCampfire(FIRE_POS); levelReady=true; setStatus("world loaded"); if(playMode) initialPlace(true); }, undefined, ()=> setStatus("ERR loading model")); /* Music: autoplay + 'M' toggle */ const audio=new Audio("https://world.seppjm.com/3js/audio/stalker.mp3"); audio.loop=true; audio.preload="auto"; audio.crossOrigin="anonymous"; audio.volume=0.5; let isPlaying=false, fadeRAF=null; const fadeTo=(target,ms=700)=>{ cancelAnimationFrame(fadeRAF); const start=audio.volume,d=target-start,t0=performance.now(); const tick=(now)=>{ const p=Math.min(1,(now-t0)/ms); audio.volume=Math.max(0,Math.min(1,start+d*p)); if(p<1) fadeRAF=requestAnimationFrame(tick); }; fadeRAF=requestAnimationFrame(tick); }; async function playMusic(auto=false){ try{ if(auto) audio.volume=0.0; await audio.play(); isPlaying=true; if(auto) fadeTo(0.5,800); }catch{} } function pauseMusic(){ fadeTo(0.0,300); setTimeout(()=>audio.pause(),320); isPlaying=false; } playMusic(true); ["pointerdown","keydown","visibilitychange"].forEach((ev)=> addEventListener(ev, ()=>{ if(!isPlaying) playMusic(true); }, { once:true })); /* fps controller + collisions (player) */ let controls=null; const keys=new Set(); const velocity=new THREE.Vector3(); let onGround=false, physicsEnabled=false; const lastSafePos=new THREE.Vector3(); let timeSinceGrounded=0, lastGroundedAt=0; let playerLight=null; function ensurePlayerLight(obj){ if(playerLight) return; playerLight=new THREE.PointLight(0xffa25a,0.9,1500*SCALE,1.4); playerLight.position.set(0,-PLAYER_EYE_HEIGHT*0.2,0); obj.add(playerLight); } /* spectator freecam */ let spectateMode=false, specControls=null; function onSpecKeyDown(e){ keys.add(e.code); if(e.code==="KeyM"){ if(isPlaying) pauseMusic(); else playMusic(); } } function onSpecKeyUp(e){ keys.delete(e.code); } function makeSpectateHint(){ const hint=document.createElement("div"); Object.assign(hint.style,{ position:"fixed", left:"16px", bottom:"16px", zIndex:20, color:"#fff", font:"600 14px Inter, system-ui", opacity:"0.9", pointerEvents:"none", textAlign:"left", lineHeight:"1.35", background:"rgba(0,0,0,0.45)", padding:"8px 10px", borderRadius:"10px", maxWidth:"44ch" }); hint.textContent="Spectator freecam — WASD move • Mouse look • Q/E down/up • Shift boost • M music • ESC cursor"; document.body.appendChild(hint); } function startSpectate(){ if(spectateMode || playMode) return; spectateMode=true; canvas.style.zIndex = "10"; canvas.style.pointerEvents = "auto"; canvas.style.opacity = "1"; labelsLayer.style.zIndex = "15"; specControls = new PointerLockControls(camera, canvas); scene.add(specControls.getObject()); specControls.lock(); canvas.addEventListener("click", ()=> specControls.lock()); specControls.addEventListener("lock", ()=> setStatus("spectate: locked (WASD, mouse, Q/E, Shift, M)")); specControls.addEventListener("unlock", ()=> setStatus("spectate: unlocked (click to lock)")); addEventListener("keydown", onSpecKeyDown); addEventListener("keyup", onSpecKeyUp); makeSpectateHint(); } function stopSpectate(){ if(!spectateMode) return; removeEventListener("keydown", onSpecKeyDown); removeEventListener("keyup", onSpecKeyUp); if(specControls){ try{ specControls.unlock(); }catch{} scene.remove(specControls.getObject()); } specControls=null; spectateMode=false; } function updateFreecam(dt){ if(!spectateMode) return; const boost = (keys.has("ShiftLeft") || keys.has("ShiftRight")) ? 3.0 : 1.0; const speed = FREECAM_SPEED * boost; const fwd=new THREE.Vector3(); camera.getWorldDirection(fwd); fwd.normalize(); const right=new THREE.Vector3().crossVectors(fwd,new THREE.Vector3(0,1,0)).normalize(); const up=new THREE.Vector3(0,1,0); const move=new THREE.Vector3(); if(keys.has("KeyW")) move.add(fwd); if(keys.has("KeyS")) move.addScaledVector(fwd,-1); if(keys.has("KeyA")) move.addScaledVector(right,-1); if(keys.has("KeyD")) move.add(right); if(keys.has("KeyE")) move.add(up); if(keys.has("KeyQ")) move.addScaledVector(up,-1); if(move.lengthSq()>0){ move.normalize().multiplyScalar(speed*dt); camera.position.add(move); } } /* rays */ const dirFan=Array.from({length:RAY_FAN_COUNT},(_,i)=>{ const a=(i/RAY_FAN_COUNT)*Math.PI*2; return new THREE.Vector3(Math.sin(a),0,Math.cos(a)).normalize(); }); const raycaster=new THREE.Raycaster(); raycaster.firstHitOnly=true; /* helpers */ const feetYFromEye=(eyeY)=> eyeY - (PLAYER_EYE_HEIGHT - CAPSULE_RADIUS); function groundSnap(eyePos, maxUp=SNAP_EPS){ if(!colliderMesh) return false; const feetY=feetYFromEye(eyePos.y), origin=new THREE.Vector3(eyePos.x, feetY+CAPSULE_RADIUS*0.75, eyePos.z), dir=new THREE.Vector3(0,-1,0); raycaster.set(origin,dir); raycaster.near=0; raycaster.far=SNAP_CAST_DIST; const hit=raycaster.intersectObject(colliderMesh,false)[0]; if(!hit) return false; const n=hit.face?.normal ?? new THREE.Vector3(0,1,0); if(n.y < WALL_NORMAL_Y) return false; const desiredFeet=hit.point.y + CAPSULE_RADIUS + CONTACT_OFFSET, delta=desiredFeet - feetY; if(delta>-maxUp && delta=WALL_NORMAL_Y) continue; const push=radius - hit.distance + CONTACT_OFFSET; if(push>0) eyePos.addScaledVector(d,-push); } } } } function sweepClampMove(eyePos, moveXZ){ if(!colliderMesh) return moveXZ; const len=moveXZ.length(); if(len<1e-6) return moveXZ; const dir=moveXZ.clone().normalize(), feet=feetYFromEye(eyePos.y); const heights=[ feet+CAPSULE_RADIUS*0.2, feet+CAPSULE_HEIGHT*0.5, feet+CAPSULE_HEIGHT-CAPSULE_RADIUS*0.2 ]; const radius=CAPSULE_RADIUS + WALL_MARGIN; let maxLen=len; for(const h of heights){ const origin=new THREE.Vector3(eyePos.x,h,eyePos.z); raycaster.set(origin,dir); raycaster.near=0; raycaster.far=len+radius; const hit=raycaster.intersectObject(colliderMesh,false)[0]; if(!hit) continue; const n=hit.face?.normal ?? new THREE.Vector3(0,1,0); if(n.y>=WALL_NORMAL_Y) continue; const allowed=Math.max(0, hit.distance - (radius+CONTACT_OFFSET)); if(allowedcolliderMaxY+200*SCALE){ eye.y=colliderMaxY+200*SCALE; velocity.y=Math.min(0,velocity.y); } } function updateFPS(dt){ if(!playMode || !levelReady || !physicsEnabled) return; const obj=controls.getObject(); const fwd=new THREE.Vector3(); camera.getWorldDirection(fwd); fwd.y=0; fwd.normalize(); const right=new THREE.Vector3().crossVectors(fwd,new THREE.Vector3(0,1,0)).normalize(); const wish=new THREE.Vector3(); if(keys.has("KeyW")) wish.add(fwd); if(keys.has("KeyS")) wish.addScaledVector(fwd,-1); if(keys.has("KeyA")) wish.addScaledVector(right,-1); if(keys.has("KeyD")) wish.add(right); if(wish.lengthSq()>0) wish.normalize(); const accel=onGround?ACCEL:AIR_ACCEL, targetVel=wish.multiplyScalar(WALK_SPEED), horizVel=new THREE.Vector3(velocity.x,0,velocity.z), add=new THREE.Vector3().subVectors(targetVel,horizVel).multiplyScalar(accel*dt); horizVel.add(add); velocity.x=horizVel.x; velocity.z=horizVel.z; velocity.y -= GRAVITY*dt; const moveLen=velocity.clone().multiplyScalar(dt).length(), steps=Math.max(1, Math.ceil(moveLen / MAX_SUBSTEP_DIST)); onGround=false; const subDt=dt/steps; for(let i=0;i4.0){ respawnAtFire(); } ensurePlayerLight(obj); } /* remote players + 2D name labels */ const players=new Map(); function makeLabelEl(text){ const el=document.createElement("div"); el.textContent=text; Object.assign(el.style,{ position:"absolute", transform:"translate(-50%,-100%)", color:"#fff", fontWeight:"800", fontSize:"22px", lineHeight:"1", padding:"4px 8px", borderRadius:"8px", background:"rgba(0,0,0,0.55)", textShadow:"0 1px 2px rgba(0,0,0,0.65)", whiteSpace:"nowrap", pointerEvents:"none", willChange:"transform", }); labelsLayer.appendChild(el); return el; } function updateLabelPosition(label, worldPos){ const v = worldPos.clone().project(camera); if (v.z < 0 || v.z > 1) { label.style.display="none"; return; } const x = (v.x * 0.5 + 0.5) * innerWidth; const y = ( -v.y * 0.5 + 0.5) * innerHeight; label.style.display=""; label.style.left = `${x}px`; label.style.top = `${y}px`; } function makeBillboardTexture(cb){ if(PLAYER_SPRITE_URL){ const img=new Image(); img.crossOrigin="anonymous"; img.onload=()=>{ const tex=new THREE.Texture(img); tex.needsUpdate=true; tex.generateMipmaps=false; tex.minFilter=THREE.NearestFilter; tex.magFilter=THREE.NearestFilter; cb(tex); }; img.onerror=()=>cb(makeProceduralBillboard()); img.src=PLAYER_SPRITE_URL; } else cb(makeProceduralBillboard()); } function makeProceduralBillboard(){ const W=64,H=128,c=document.createElement("canvas"); c.width=W; c.height=H; const g=c.getContext("2d"); g.fillStyle="rgba(0,0,0,0)"; g.fillRect(0,0,W,H); g.fillStyle="#d6e0ff"; g.fillRect(18,20,28,78); g.beginPath(); g.arc(32,30,18,0,Math.PI*2); g.fill(); g.fillRect(18,98,10,24); g.fillRect(36,98,10,24); g.strokeStyle="#131a2f"; g.lineWidth=3; g.strokeRect(18,20,28,78); g.beginPath(); g.arc(32,30,18,0,Math.PI*2); g.stroke(); const tex=new THREE.CanvasTexture(c); tex.generateMipmaps=false; tex.minFilter=THREE.NearestFilter; tex.magFilter=THREE.NearestFilter; return tex; } function makeBillboardSprite(onReady){ makeBillboardTexture((tex)=>{ const spr=new THREE.Sprite(new THREE.SpriteMaterial({ map:tex, transparent:true, depthWrite:true, depthTest:true })); spr.center.set(0.5, 0.0); // bottom at ground spr.position.y = 0; spr.scale.set(SPRITE_HEIGHT*SPRITE_ASPECT, SPRITE_HEIGHT, 1); onReady(spr); }); } /* spawn/update remote, anchor to ground (pos is eye from server) */ function spawnOrUpdate(p){ if (myID && p?.id === myID) return; // ignore self const baseY = (p.pos?.[1] ?? 0) - PLAYER_EYE_HEIGHT; let entry = players.get(p.id); if(!entry){ const group=new THREE.Group(); group.position.set(p.pos?.[0]??0, baseY, p.pos?.[2]??0); group.rotation.y=p.rotY||0; makeBillboardSprite((body)=>{ group.add(body); }); scene.add(group); const labelEl = makeLabelEl(p.name || "Player"); entry = { group, labelEl, target:new THREE.Vector3(p.pos?.[0]??0, baseY, p.pos?.[2]??0), rotY:p.rotY||0, name:p.name||"Player" }; players.set(p.id, entry); } else { entry.target.set(p.pos?.[0]??0, baseY, p.pos?.[2]??0); entry.rotY = p.rotY || 0; if (p.name && p.name !== entry.name) { entry.name = p.name; entry.labelEl.textContent = p.name; } } } function removePlayer(id){ const e=players.get(id); if(!e) return; scene.remove(e.group); if(e.labelEl?.parentNode) e.labelEl.parentNode.removeChild(e.labelEl); players.delete(id); } /* chat UI (only when playing) */ function makeChatUI(){ const hint=document.createElement("div"); Object.assign(hint.style,{ position:"fixed", left:"16px", bottom:"16px", zIndex:20, color:"#fff", font:"600 14px Inter, system-ui", opacity:"0.9", pointerEvents:"none", textAlign:"left", lineHeight:"1.35", background:"rgba(0,0,0,0.45)", padding:"8px 10px", borderRadius:"10px", maxWidth:"44ch" }); hint.innerHTML=`Pointer-lock FPS
WASD move • SPACE jump • T chat • M music • ESC cursor`; document.body.appendChild(hint); const log=document.createElement("div"); Object.assign(log.style,{ position:"fixed", left:"16px", bottom:"96px", width:"420px", maxHeight:"40vh", overflow:"hidden", display:"flex", flexDirection:"column-reverse", gap:"6px", zIndex:20, font:"15px/1.35 Inter, system-ui", color:"#fff" }); log.id="chat-log"; document.body.appendChild(log); const form=document.createElement("form"); form.id="chat-form"; Object.assign(form.style,{ position:"fixed", left:"16px", bottom:"16px", zIndex:21, display:"none" }); const inp=document.createElement("input"); inp.type="text"; inp.placeholder="type to chat…"; Object.assign(inp.style,{ width:"400px", padding:"10px 12px", borderRadius:"10px", border:"1px solid rgba(255,255,255,.2)", background:"rgba(0,0,0,.5)", color:"#fff" }); form.appendChild(inp); document.body.appendChild(form); form.addEventListener("submit",(e)=>{ e.preventDefault(); const t=inp.value.trim(); if(!t) return; send({type:"chat", text:t}); inp.value=""; hideChat(); }); function showChat(){ form.style.display="block"; inp.focus(); controls.unlock(); } function hideChat(){ form.style.display="none"; controls.lock(); } addEventListener("keydown",(e)=>{ if(e.key==="t"||e.key==="T"){ e.preventDefault(); showChat(); }}); addEventListener("keydown",(e)=>{ if(e.code==="KeyM"){ if(isPlaying) pauseMusic(); else playMusic(); setStatus(isPlaying ? "music: playing" : "music: paused"); } }); } function addChat(logEl, name, text){ if(!logEl) return; const row=document.createElement("div"); row.textContent=`${name}: ${text}`; row.style.background="rgba(0,0,0,.45)"; row.style.padding="6px 10px"; row.style.borderRadius="8px"; logEl.prepend(row); const kids=[...logEl.children]; while(kids.length>24){ logEl.removeChild(kids.pop()); } } /* networking */ let ws=null, myID=null, lastSend=0, currentRole="spectator", suppressToast=false; function connect(role="spectator", name="Viewer"){ try{ if(ws && ws.readyState<=1){ suppressToast=true; try{ ws.close(); }catch{} } currentRole=role; ws=new WebSocket(WS_URL); ws.binaryType="arraybuffer"; ws.addEventListener("open", ()=>{ setStatus(`ws: connected (${role})`); send({ type:"join", name, role }); if(role==="player"){ const p=controls?.getObject?.().position; if(p) send({type:"state", pos:[p.x,p.y,p.z], rotY:camera.rotation.y}); } }); ws.addEventListener("message",(ev)=>{ try{ onMessage(ev); }catch(e){ setStatus("ws msg err: "+e.message); } }); ws.addEventListener("close", ()=>{ if(!suppressToast){ const toast=document.createElement("div"); Object.assign(toast.style,{ position:"fixed", left:"50%", top:"14px", transform:"translateX(-50%)", background:"rgba(200,0,0,0.75)", color:"#fff", padding:"8px 12px", borderRadius:"10px", zIndex:50, font:"600 14px Inter, system-ui" }); toast.textContent="Disconnected from server"; document.body.appendChild(toast); setTimeout(()=>toast.remove(),3500); } suppressToast=false; setStatus("ws: disconnected"); }); ws.addEventListener("error", ()=> setStatus("ws: error")); }catch(e){ setStatus("ws init err: "+e.message); } } function send(obj){ if(ws && ws.readyState===1) ws.send(encode(obj)); } function onMessage(ev){ const msg=decode(ev.data); switch(msg.type){ case "welcome": myID = msg.id; removePlayer(myID); (msg.players||[]).forEach((p)=>{ if(p.id!==myID) spawnOrUpdate(p); }); break; case "player_join": if(msg.player && msg.player.id !== myID) spawnOrUpdate(msg.player); break; case "player_leave": if(msg.id) removePlayer(msg.id); break; case "state": if(Array.isArray(msg.updates)) msg.updates.forEach((u)=>{ if(u.id!==myID) spawnOrUpdate(u); }); else if(msg.id && msg.pos){ if(msg.id!==myID) spawnOrUpdate(msg); } break; case "player_state": if(msg.id && msg.pos && msg.id!==myID) spawnOrUpdate(msg); break; case "chat": addChat(document.getElementById("chat-log"), msg.name||"Player", msg.text||""); break; case "full": alert(`World is full (max ${msg.maxPlayers ?? 10})`); break; } } /* enter play */ function startPlay(){ if(playMode) return; // if we were spectating, clean it up if(spectateMode) stopSpectate(); playMode=true; canvas.style.zIndex = "10"; canvas.style.pointerEvents = "auto"; canvas.style.opacity = "1"; labelsLayer.style.zIndex = "15"; const controlsLocal=new PointerLockControls(camera, canvas); controls=controlsLocal; scene.add(controls.getObject()); controls.lock(); canvas.addEventListener("click", ()=>controls.lock()); controls.addEventListener("lock", ()=> setStatus("locked (WASD, SPACE jump, T chat, M music)")); controls.addEventListener("unlock", ()=> setStatus("unlocked (click to lock)")); makeChatUI(); if(levelReady) initialPlace(true); const name = (prompt("Authentication not implemented; for now enter a nickname:", "Guest") || "Guest").slice(0,25); connect("player", name); addEventListener("keydown",(e)=>{ keys.add(e.code); if(e.code==="Space" && playMode){ const now=performance.now(); if(onGround || (now - lastGroundedAt) < COYOTE_TIME*1000){ velocity.y = JUMP_STRENGTH; onGround=false; } } }); addEventListener("keyup",(e)=> keys.delete(e.code)); } /* join hooks */ (function hookJoin(){ const candidates=[ '#join-btn', '#spectate-btn', 'a[href="#Join"]','a[href="#join"]', 'a.button[href="#About"]','#join','[data-join]','.join','button.join' ]; for(const sel of candidates){ const el=document.querySelector(sel); if(el){ el.addEventListener("click",(e)=>{ e.preventDefault(); if(el.id === "spectate-btn"){ startSpectate(); setStatus("Spectating…"); }else{ startPlay(); } }); } } addEventListener("keydown",(e)=>{ if(e.code==="Enter" && !playMode) startPlay(); }); })(); /* spectator connect on boot */ const defaultSpectatorName = `Viewer${Math.floor((Math.random()*9000)+1000)}`; connect("spectator", defaultSpectatorName); /* loop */ const clock=new THREE.Clock(); let active=true; addEventListener("visibilitychange",()=>{ active=document.visibilityState==="visible"; if(active) requestAnimationFrame(animate); }); requestAnimationFrame(()=>{ animate(); canvas.style.opacity="1"; }); let fpsAccum=0,fpsCount=0,scaleCooldown=0; function animate(){ if(!active) return; const dt=Math.min(0.05, clock.getDelta()); const now=performance.now(); if(mixer) mixer.update(dt); if(playMode) updateFPS(dt); if(spectateMode) updateFreecam(dt); // smooth remote players and update 2D labels players.forEach(({group,target,rotY,labelEl})=>{ const k=1 - Math.pow(0.0001, dt*60); group.position.lerp(target,k); group.rotation.y += (rotY - group.rotation.y)*k; const headWorld = new THREE.Vector3( group.position.x, group.position.y + SPRITE_HEIGHT + NAME_PAD, group.position.z ); updateLabelPosition(labelEl, headWorld); }); perFrame.forEach((fn)=>fn(dt)); renderer.setRenderTarget(rt); postMat.uniforms.tDiffuse.value=null; renderer.clear(); renderer.render(scene,camera); renderer.setRenderTarget(null); postMat.uniforms.tDiffuse.value=rt.texture; postMat.uniforms.uTime.value += dt; renderer.render(postScene,postCam); fpsAccum += dt; fpsCount++; if(now - scaleCooldown > 1000){ const avg=fpsAccum/Math.max(1,fpsCount); if(avg>0.03 && RENDER_SCALE<6){ RENDER_SCALE+=0.5; allocRT(); } else if(avg<0.017 && RENDER_SCALE>2){ RENDER_SCALE-=0.5; allocRT(); } fpsAccum=0; fpsCount=0; scaleCooldown=now; } if(currentRole==="player" && ws && ws.readyState===1 && now - lastSend >= SEND_RATE_MS){ lastSend=now; const p=controls.getObject().position; send({type:"state", pos:[p.x,p.y,p.z], rotY:camera.rotation.y}); } if(!playMode && !spectateMode) updateCameraHover(dt); requestAnimationFrame(animate); } /* resize */ addEventListener("resize",()=>{ camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix(); allocRT(); patchedMaterials.forEach((m)=>{ const u = ps1Uniforms.get(m); if(u?.uResolution?.value){ u.uResolution.value.set(innerWidth, innerHeight); } }); });