import * as THREE from "three"; import { FBXLoader } from "three/addons/loaders/FBXLoader.js"; import { TGALoader } from "three/addons/loaders/TGALoader.js"; /* msgpack for server messages */ import { encode, decode } from "https://esm.sh/@msgpack/msgpack@3.1.2?bundle"; (() => { if (window.__threePS1Campfire) return; window.__threePS1Campfire = true; const MODEL_URL = "https://seppjm.com/3js/models/scene.fbx"; const NORMALIZE_SCALE = false; const USE_FOG = true; const PS1_SCALE = 3; const COLOR_LEVELS = 32; const USE_DITHER = true; const 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; const FIRE_SPRITE_SIZE = 150.0; const FIRE_HEIGHT_OFFSET = 0.25; const FIRE_MAIN_INTENSITY = 2.2; const FIRE_SUB_INTENSITY = 1.0; const FIRE_MAIN_DISTANCE = 12000; const FIRE_SUB_DISTANCE = 6000; const FIRE_DECAY = 1.2; /* --- WS spectator (minimal) --- */ const WS_URL = "wss://ws.world.seppjm.com/ws"; // adjust if you proxy to wss const REMOTE_SPRITE_HEIGHT = 120; const REMOTE_SPRITE_ASPECT = 0.55; const players = new Map(); // id -> {group, target, rotY} let myID = null; let ws = null; function makeProceduralBillboard() { const W = 64, H = 128; const 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() { const tex = makeProceduralBillboard(); 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-ish spr.position.y = 0; spr.scale.set(REMOTE_SPRITE_HEIGHT * REMOTE_SPRITE_ASPECT, REMOTE_SPRITE_HEIGHT, 1); return spr; } function spawnOrUpdate(p) { if (myID && p.id === myID) return; let entry = players.get(p.id); if (!entry) { const group = new THREE.Group(); if (p.pos?.length === 3) group.position.set(p.pos[0], p.pos[1], p.pos[2]); group.rotation.y = p.rotY || 0; group.add(makeBillboardSprite()); scene.add(group); entry = { group, target: new THREE.Vector3().copy(group.position), rotY: group.rotation.y }; players.set(p.id, entry); } else { if (p.pos?.length === 3) entry.target.set(p.pos[0], p.pos[1], p.pos[2]); entry.rotY = p.rotY || 0; } } function removePlayer(id) { const e = players.get(id); if (!e) return; scene.remove(e.group); players.delete(id); } function connectSpectator() { try { ws = new WebSocket(WS_URL); ws.binaryType = "arraybuffer"; ws.addEventListener("open", () => { const name = `Viewer${Math.floor(Math.random()*9000)+1000}`; ws.send(encode({ type: "join", name, role: "spectator" })); }); ws.addEventListener("message", (ev) => { const msg = decode(ev.data); switch (msg.type) { case "welcome": myID = msg.id || null; (msg.players || []).forEach(spawnOrUpdate); break; case "player_join": if (msg.player) spawnOrUpdate(msg.player); break; case "player_leave": if (msg.id) removePlayer(msg.id); break; case "state": if (Array.isArray(msg.updates)) msg.updates.forEach(spawnOrUpdate); else if (msg.id && msg.pos) spawnOrUpdate(msg); break; } }); } catch {} } /* --- end WS spectator --- */ const footer = document.querySelector("footer"); const canvas = document.createElement("canvas"); canvas.id = "three-bg"; Object.assign(canvas.style, { position: "fixed", inset: "0", width: "100vw", height: "100vh", display: "block", zIndex: "0", pointerEvents: "none", clipPath: "inset(0 0 var(--bg-clip-bottom, 0px) 0)", WebkitClipPath: "inset(0 0 var(--bg-clip-bottom, 0px) 0)", opacity: "0", transition: "opacity 1400ms ease", imageRendering: "pixelated", }); document.body.prepend(canvas); function updateFooterClip() { const fadeLen = 140; const rect = footer?.getBoundingClientRect(); if (!rect) { canvas.style.setProperty("--bg-clip-bottom", "0px"); canvas.style.maskImage = "none"; canvas.style.WebkitMaskImage = "none"; return; } const overlap = Math.max(0, innerHeight - rect.top); const clip = Math.max(0, overlap - fadeLen); canvas.style.setProperty("--bg-clip-bottom", `${clip}px`); if (overlap > 0) { const grad = `linear-gradient(to bottom, black 0%, black calc(100% - ${fadeLen}px), transparent 100%)`; canvas.style.maskImage = grad; canvas.style.WebkitMaskImage = grad; } else { canvas.style.maskImage = "none"; canvas.style.WebkitMaskImage = "none"; } } addEventListener("scroll", updateFooterClip, { passive: true }); addEventListener("resize", updateFooterClip); new ResizeObserver(updateFooterClip).observe(document.body); if (footer) new ResizeObserver(updateFooterClip).observe(footer); updateFooterClip(); 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); 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.0 }, }, vertexShader: ` varying vec2 vUv; void main() { vUv = (position.xy + 1.0) * 0.5; gl_Position = vec4(position.xy, 0.0, 1.0); } `, fragmentShader: ` precision mediump float; uniform sampler2D tDiffuse; uniform vec2 uLowRes; uniform int uLevels; uniform int 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) - 0.5) : 0.0; vec3 q = floor(clamp(c + t / L, 0.0, 1.0) * (L - 1.0) + 0.5) / (L - 1.0); return q; } void main() { vec2 p = vUv * uLowRes; vec2 uv = (floor(p) + 0.5) / uLowRes; vec3 col = texture2D(tDiffuse, uv).rgb; col = quantize(col, p); gl_FragColor = vec4(col, 1.0); } `, depthTest: false, depthWrite: false, }); postScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), postMat)); function resizeRenderer() { const w = innerWidth, h = innerHeight; lowW = Math.max(1, Math.floor(w / PS1_SCALE)); lowH = Math.max(1, Math.floor(h / PS1_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); } resizeRenderer(); 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 ); camera.position.copy(CAM_POS); const BASE_YAW = THREE.MathUtils.degToRad( ((CAM_ROT_DEG.yaw % 360) + 360) % 360 ); const BASE_PITCH = THREE.MathUtils.degToRad(CAM_ROT_DEG.pitch); camera.rotation.set(BASE_PITCH, BASE_YAW, 0, "YXZ"); camera.updateProjectionMatrix(); const MAX_YAW_DELTA = THREE.MathUtils.degToRad(6); const MAX_PITCH_DELTA = THREE.MathUtils.degToRad(4); let aimYaw = BASE_YAW, aimPitch = BASE_PITCH; let curYaw = BASE_YAW, curPitch = BASE_PITCH; addEventListener( "pointermove", (e) => { const nx = (e.clientX / innerWidth) * 2 - 1; const 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 smooth = 1.0 - Math.pow(0.2, dt * 60); curYaw += (aimYaw - curYaw) * smooth; curPitch += (aimPitch - curPitch) * smooth; 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); 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, sceneRadius = 100; const patchedMaterials = new Set(); const perFrame = []; function ps1ifyMaterials(root) { root.traverse((n) => { if (!n.isMesh) return; const src = n.material; const mats = (Array.isArray(src) ? src : [src]).filter(Boolean); const newMats = mats.map((m) => { const p = { color: m.color && m.color.isColor ? m.color.clone() : new THREE.Color(0xffffff), map: m.map || null, transparent: false, alphaTest: (m.alphaTest ?? 0.0) > 0 ? m.alphaTest : m.map?.format === THREE.RGBAFormat ? 0.5 : 0.0, side: THREE.DoubleSide, }; const lm = new THREE.MeshLambertMaterial(p); ["map", "emissiveMap", "aoMap", "specularMap"].forEach((k) => { const tex = lm[k]; if (tex) { tex.generateMipmaps = false; tex.minFilter = THREE.NearestFilter; tex.magFilter = THREE.NearestFilter; tex.anisotropy = 0; tex.needsUpdate = true; } }); lm.depthWrite = true; lm.depthTest = true; lm.dithering = false; lm.polygonOffset = true; lm.polygonOffsetFactor = -0.5; lm.polygonOffsetUnits = 1.0; lm.onBeforeCompile = (shader) => { shader.uniforms.uResolution = { value: new THREE.Vector2(innerWidth, innerHeight), }; shader.uniforms.uSnapPixels = { value: VERTEX_SNAP_PIXELS }; shader.vertexShader = shader.vertexShader.replace( /void\s+main\s*\(\)\s*\{/, "uniform vec2 uResolution;\nuniform float uSnapPixels;\nvoid main(){" ); if (VERTEX_SNAP_PIXELS > 0) { 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; ` ); } lm.userData._ps1Shader = shader; patchedMaterials.add(lm); }; return lm; }); n.material = Array.isArray(src) ? newMats : newMats[0]; }); } function autoFogFor(target) { if (!USE_FOG) return; const box = new THREE.Box3().setFromObject(target); const size = box.getSize(new THREE.Vector3()); sceneRadius = Math.max(size.x, size.y, size.z) * 0.5 || 100; const k = 0.03; const density = Math.min(0.06, Math.max(0.000005, k / (sceneRadius || 1))); scene.fog = new THREE.FogExp2(0x0b1020, density); } loader.load( MODEL_URL, (fbx) => { model = fbx; if (NORMALIZE_SCALE) { const box = new THREE.Box3().setFromObject(model); const size = box.getSize(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z) || 1; const scale = 20.0 / maxDim; model.scale.setScalar(scale); const center = box.getCenter(new THREE.Vector3()).multiplyScalar(scale); model.position.sub(center); } ps1ifyMaterials(model); scene.add(model); if (fbx.animations?.length) { mixer = new THREE.AnimationMixer(model); mixer.clipAction(fbx.animations[0]).play(); } autoFogFor(model); addCampfire(FIRE_POS); }, undefined, () => { addCampfire(FIRE_POS); } ); let fireGroup = null, fireLight = null, fireLight2 = null, fireSprite = null; function makeFlameTexture(size = 128) { const c = document.createElement("canvas"); c.width = c.height = size; const g = c.getContext("2d"); const grd = g.createRadialGradient( size * 0.5, size * 0.6, size * 0.05, size * 0.5, size * 0.6, size * 0.5 ); grd.addColorStop(0.0, "rgba(255,255,255,1)"); grd.addColorStop(0.25, "rgba(255,200,80,0.95)"); grd.addColorStop(0.55, "rgba(255,120,30,0.65)"); grd.addColorStop(0.85, "rgba(200,40,10,0.25)"); grd.addColorStop(1.0, "rgba(0,0,0,0)"); g.fillStyle = grd; g.fillRect(0, 0, size, size); const tex = new THREE.CanvasTexture(c); tex.generateMipmaps = false; tex.minFilter = THREE.NearestFilter; tex.magFilter = THREE.NearestFilter; tex.anisotropy = 0; return tex; } function addCampfire(pos) { fireGroup = new THREE.Group(); fireGroup.position.copy(pos); scene.add(fireGroup); fireLight = new THREE.PointLight( 0xff7a2a, FIRE_MAIN_INTENSITY, FIRE_MAIN_DISTANCE, FIRE_DECAY ); fireLight.position.set(0, FIRE_HEIGHT_OFFSET + 0.6, 0); fireGroup.add(fireLight); fireLight2 = new THREE.PointLight( 0xff3300, FIRE_SUB_INTENSITY, FIRE_SUB_DISTANCE, FIRE_DECAY ); fireLight2.position.set(0.35, FIRE_HEIGHT_OFFSET + 0.4, -0.2); fireGroup.add(fireLight2); const flameTex = makeFlameTexture(128); const mat = new THREE.SpriteMaterial({ map: flameTex, color: 0xffffff, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, depthTest: true, }); fireSprite = new THREE.Sprite(mat); fireSprite.position.set(0, FIRE_HEIGHT_OFFSET + 0.25, 0); fireSprite.scale.set(FIRE_SPRITE_SIZE, FIRE_SPRITE_SIZE * 1.35, 1); fireGroup.add(fireSprite); const base1 = FIRE_MAIN_INTENSITY, base2 = FIRE_SUB_INTENSITY; let t = 0; perFrame.push((dt) => { t += dt; const pulse = 0.45 * Math.sin(t * 5.4) + 0.28 * Math.sin(t * 8.7 + 1.1); fireLight.intensity = base1 + pulse; fireLight2.intensity = base2 + 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 ); }); } function animate() { if (!active) return; const dt = clock.getDelta(); if (mixer) mixer.update(dt); updateCameraHover(dt); perFrame.forEach((fn) => fn(dt)); /* smooth remote players */ players.forEach(({ group, target, rotY }) => { const k = 1 - Math.pow(0.0001, dt * 60); group.position.lerp(target, k); group.rotation.y += (rotY - group.rotation.y) * k; }); 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); requestAnimationFrame(animate); } const clock = new THREE.Clock(); let active = true; addEventListener("visibilitychange", () => { active = document.visibilityState === "visible"; if (active) requestAnimationFrame(animate); }); const fadeIn = () => requestAnimationFrame(() => (canvas.style.opacity = "1")); requestAnimationFrame(() => { animate(); requestAnimationFrame(fadeIn); }); addEventListener("resize", () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); resizeRenderer(); updateFooterClip(); patchedMaterials.forEach((m) => { const s = m.userData._ps1Shader; if (s) s.uniforms.uResolution.value.set(innerWidth, innerHeight); }); }); const audio = new Audio("https://seppjm.com/3js/audio/stalker.mp3"); audio.loop = true; audio.preload = "auto"; audio.crossOrigin = "anonymous"; audio.volume = 0.5; const btn = document.createElement("button"); btn.type = "button"; btn.textContent = "Play music"; btn.setAttribute("aria-pressed", "false"); btn.ariaLabel = "Toggle background music"; Object.assign(btn.style, { position: "fixed", right: "16px", bottom: "16px", zIndex: "3", padding: "10px 14px", borderRadius: "9999px", border: "1px solid rgba(255,255,255,0.25)", background: "rgba(0,0,0,0.45)", color: "white", font: "500 14px/1.1 system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif", backdropFilter: "blur(6px)", WebkitBackdropFilter: "blur(6px)", cursor: "pointer", userSelect: "none", }); document.body.appendChild(btn); let isPlaying = false, fadeRAF = null; function fadeTo(target, ms = 700) { cancelAnimationFrame(fadeRAF); const start = audio.volume, delta = 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 + delta * p)); if (p < 1) fadeRAF = requestAnimationFrame(tick); }; fadeRAF = requestAnimationFrame(tick); } async function playAudio() { try { await audio.play(); isPlaying = true; btn.textContent = "Pause music"; btn.setAttribute("aria-pressed", "true"); fadeTo(0.5, 600); } catch {} } function pauseAudio() { fadeTo(0.0, 400); setTimeout(() => audio.pause(), 420); isPlaying = false; btn.textContent = "Play music"; btn.setAttribute("aria-pressed", "false"); } btn.addEventListener("click", () => isPlaying ? pauseAudio() : playAudio() ); const startOnGesture = () => { if (!isPlaying) playAudio(); removeEventListener("pointerdown", startOnGesture); removeEventListener("keydown", startOnGesture); }; addEventListener("pointerdown", startOnGesture, { once: true }); addEventListener("keydown", startOnGesture, { once: true }); /* start spectator connection */ connectSpectator(); })();