init project release
This commit is contained in:
BIN
Frontend-WEB/world.seppjm.com/3js/audio/stalker.mp3
Normal file
BIN
Frontend-WEB/world.seppjm.com/3js/audio/stalker.mp3
Normal file
Binary file not shown.
655
Frontend-WEB/world.seppjm.com/3js/background.js
Normal file
655
Frontend-WEB/world.seppjm.com/3js/background.js
Normal file
@@ -0,0 +1,655 @@
|
||||
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 <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://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();
|
||||
})();
|
BIN
Frontend-WEB/world.seppjm.com/3js/models/scene.fbx
Normal file
BIN
Frontend-WEB/world.seppjm.com/3js/models/scene.fbx
Normal file
Binary file not shown.
682
Frontend-WEB/world.seppjm.com/3js/world.js
Normal file
682
Frontend-WEB/world.seppjm.com/3js/world.js
Normal file
@@ -0,0 +1,682 @@
|
||||
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 <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;
|
||||
`
|
||||
);
|
||||
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<geos.length;i+=64){ chunks.push(BufferGeometryUtils.mergeGeometries(geos.slice(i,i+64),false)); } merged=BufferGeometryUtils.mergeGeometries(chunks,false); }
|
||||
merged = BufferGeometryUtils.mergeVertices(merged,1e-3); merged.computeBoundingBox(); merged.computeBoundingSphere();
|
||||
colliderGeom=merged; colliderBVH=new MeshBVH(colliderGeom,{maxLeafTris:32}); colliderGeom.boundsTree=colliderBVH;
|
||||
colliderMesh=new THREE.Mesh(colliderGeom,new THREE.MeshBasicMaterial({visible:false}));
|
||||
colliderMinY = colliderGeom.boundingBox ? colliderGeom.boundingBox.min.y : -1e6;
|
||||
colliderMaxY = colliderGeom.boundingBox ? colliderGeom.boundingBox.max.y : +1e6;
|
||||
}
|
||||
|
||||
/* fog tuning vs scene size */
|
||||
function autoFogFor(target){
|
||||
const box=new THREE.Box3().setFromObject(target), size=box.getSize(new THREE.Vector3());
|
||||
const k=0.03, density=Math.min(0.06, Math.max(0.000005, k/(Math.max(size.x,size.y,size.z)*0.5 || 1)));
|
||||
scene.fog = USE_FOG ? new THREE.FogExp2(0x0b1020, density) : null;
|
||||
}
|
||||
|
||||
/* campfire */
|
||||
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*.5,size*.6,size*.05,size*.5,size*.6,size*.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);
|
||||
fireSprite=new THREE.Sprite(new THREE.SpriteMaterial({ map:flameTex, color:0xffffff, transparent:true, depthWrite:false, blending:THREE.AdditiveBlending, depthTest:true }));
|
||||
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);
|
||||
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=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<maxUp){ eyePos.y += delta; return true; } return false;
|
||||
}
|
||||
function pushOutWalls(eyePos){
|
||||
if(!colliderMesh) return;
|
||||
const feet=feetYFromEye(eyePos.y), heights=[ feet+CAPSULE_RADIUS*0.2, feet+CAPSULE_HEIGHT*0.5, feet+CAPSULE_HEIGHT-CAPSULE_RADIUS*0.2 ];
|
||||
const radius=CAPSULE_RADIUS + WALL_MARGIN;
|
||||
for(let iter=0; iter<WALL_ITER; iter++){
|
||||
for(const h of heights){
|
||||
for(const d of dirFan){
|
||||
const o=new THREE.Vector3(eyePos.x,h,eyePos.z); raycaster.set(o,d); raycaster.near=0; raycaster.far=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 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(allowed<maxLen) maxLen=allowed;
|
||||
}
|
||||
if(maxLen<len) moveXZ.setLength(maxLen); return moveXZ;
|
||||
}
|
||||
|
||||
/* spawn at fire */
|
||||
function initialPlace(forceHigh=false){
|
||||
if(!levelReady) return;
|
||||
const obj=controls.getObject(); const spawn=FIRE_POS.clone().add(SPAWN_OFFSET); const y=(forceHigh?SPAWN_EXTRA_Y:5*SCALE);
|
||||
obj.position.copy(spawn).add(new THREE.Vector3(0,y,0));
|
||||
const toFire=new THREE.Vector3().subVectors(FIRE_POS,spawn), yaw=Math.atan2(toFire.x,toFire.z);
|
||||
obj.rotation.set(0,yaw,0,"YXZ"); camera.rotation.set(0,yaw,0,"YXZ");
|
||||
physicsEnabled=true; velocity.set(0,0,0); onGround=false; timeSinceGrounded=0; lastGroundedAt=0; lastSafePos.copy(obj.position); setStatus("ready");
|
||||
}
|
||||
function respawnAtFire(){ const obj=controls.getObject(), spawn=FIRE_POS.clone().add(SPAWN_OFFSET); obj.position.copy(spawn).add(new THREE.Vector3(0,SPAWN_EXTRA_Y,0)); velocity.set(0,0,0); onGround=false; timeSinceGrounded=0; setStatus("respawn"); }
|
||||
|
||||
/* physics step */
|
||||
function integrateSubstep(subDt){
|
||||
const obj=controls.getObject(), eye=obj.position, move=velocity.clone().multiplyScalar(subDt), moveXZ=new THREE.Vector3(move.x,0,move.z);
|
||||
sweepClampMove(eye,moveXZ); eye.add(moveXZ); pushOutWalls(eye);
|
||||
eye.y += move.y; pushOutWalls(eye);
|
||||
|
||||
if (velocity.y <= 0) {
|
||||
const snapped=groundSnap(eye, SNAP_EPS + Math.max(0,-velocity.y*subDt + CONTACT_OFFSET));
|
||||
if(snapped){ onGround=true; if(velocity.y<0) velocity.y=0; lastGroundedAt=performance.now(); }
|
||||
}
|
||||
|
||||
if(Number.isFinite(colliderMinY)){ const minEye=colliderMinY + (PLAYER_EYE_HEIGHT - CAPSULE_RADIUS) + 0.25*SCALE; if(eye.y<minEye){ eye.y=minEye; velocity.y=Math.max(0,velocity.y); } }
|
||||
if(Number.isFinite(colliderMaxY) && eye.y>colliderMaxY+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;i<steps;i++) integrateSubstep(subDt);
|
||||
|
||||
if(velocity.y <= 0 && groundSnap(obj.position)) { onGround=true; lastGroundedAt=performance.now(); }
|
||||
|
||||
if(onGround){ const f=Math.max(0,1-FRICTION*dt); velocity.x*=f; velocity.z*=f; if(velocity.y<0) velocity.y=0; timeSinceGrounded=0; lastSafePos.copy(obj.position); setStatus("grounded"); }
|
||||
else { timeSinceGrounded+=dt; setStatus("air"); }
|
||||
|
||||
const minEye=colliderMinY + (PLAYER_EYE_HEIGHT - CAPSULE_RADIUS) - 10*SCALE; if(obj.position.y<minEye || timeSinceGrounded>4.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<br>WASD move • <b>SPACE</b> jump • <b>T</b> chat • <b>M</b> music • <b>ESC</b> 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 — match your exact buttons and href */
|
||||
(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); }
|
||||
});
|
||||
});
|
68
Frontend-WEB/world.seppjm.com/index.html
Normal file
68
Frontend-WEB/world.seppjm.com/index.html
Normal file
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Basic Page Needs -->
|
||||
<meta charset="utf-8" />
|
||||
<title>SEPPJM.COM | Join World</title>
|
||||
<meta name="description" content="World | SeppJM.com" />
|
||||
<meta name="author" content="Sepp -seppdroid- Jeremiah Morris" />
|
||||
|
||||
<!-- Mobile Specific Metas -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<!-- FONT -->
|
||||
<link href="//sjm.cdn.prutzel.com/fonts/crn/font.css" rel="stylesheet" type="text/css" />
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="//sjm.cdn.prutzel.com/css/normalize.css" />
|
||||
<link rel="stylesheet" href="//sjm.cdn.prutzel.com/css/skeleton.css" />
|
||||
<link rel="stylesheet" href="//sjm.cdn.prutzel.com/fonts/font-awesome-4.7.0/css/font-awesome.min.css" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="//sjm.cdn.prutzel.com/images/favicon.png" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Primary Page Layout
|
||||
–––––––––––––––––––––––––––––––––––––––––––––––––– -->
|
||||
<div class="row">
|
||||
<div class="column" style="margin-top: 17%">
|
||||
<center><h3>Join World</h3></center>
|
||||
<center><p>Let's start</p></center>
|
||||
<br>
|
||||
<center>
|
||||
<a class="button" href="#Join" id="join-btn">Join</a>
|
||||
<a class="button" href="#Spectate" id="spectate-btn">Spectate</a>
|
||||
<br>
|
||||
<br>
|
||||
<a class="button" href="https://git.seppjm.com/seppdroid/world-seppjm" id="src-code-btn">Source Code</a>
|
||||
<a class="button" href="https://git.seppjm.com/seppdroid/world-seppjm/releases" id="client-btn">Download Client</a>
|
||||
<a class="button" href="steam://rungameid/3379200" id="devbox-btn">Start in DiodeMatrix DEVBOX</a>
|
||||
<br>
|
||||
<br>
|
||||
<a class="button" href="https://seppjm.com" id="back-btn">Back to SEPPJM.COM</a>
|
||||
</center><br>
|
||||
<center><p>---</p></center><br>
|
||||
<center><p>Special thanks & credits to <a href="https://www.patreon.com/McPatoo">McDuck</a> for the PSX Scene and <a href="https://senko.dev/">SENKO</a> for the song.</p></center>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JS -->
|
||||
<script async src="https://unpkg.com/es-module-shims@1.10.0/dist/es-module-shims.js"></script>
|
||||
|
||||
<!-- Import map for three.js -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Player mode overlay -->
|
||||
<script type="module" src="/3js/world.js"></script>
|
||||
</body>
|
||||
</html>
|
Reference in New Issue
Block a user