656 lines
20 KiB
JavaScript
656 lines
20 KiB
JavaScript
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://world.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";
|
|
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 <project_vertex>",
|
|
`
|
|
#include <project_vertex>
|
|
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://world.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();
|
|
})();
|