mirror of
https://github.com/PlayableIntelligence/game-creator.git
synced 2026-09-19 07:34:10 +08:00
feat(worldlabs-arcade): 3D Gaussian Splat arcade walkthrough demo
World Labs SPZ environment with animated Soldier character (Three.js + SparkJS). Fixes Y-flip, panorama doubling, character scaling, third-person camera, and ground raycasting for navigable photorealistic arcade scene. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="data:,">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>World Labs Arcade Demo</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #1a0a2e; overflow: hidden; font-family: system-ui, -apple-system, sans-serif; }
|
||||
canvas { display: block; }
|
||||
|
||||
#loading {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #1a0a2e;
|
||||
z-index: 100;
|
||||
color: #bb88ff;
|
||||
font-size: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
#loading.hidden { display: none; }
|
||||
#loading .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(187, 136, 255, 0.2);
|
||||
border-top-color: #bb88ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
#controls-hint {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: rgba(187, 136, 255, 0.7);
|
||||
font-size: 14px;
|
||||
z-index: 10;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
#controls-hint.faded { opacity: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading arcade world...</span>
|
||||
</div>
|
||||
|
||||
<div id="controls-hint">Click to look • WASD to move • Shift to run</div>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<script>
|
||||
// Fade controls hint after 5 seconds
|
||||
setTimeout(() => {
|
||||
const hint = document.getElementById('controls-hint');
|
||||
if (hint) hint.classList.add('faded');
|
||||
}, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+1130
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "worldlabs-arcade-demo",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"three": "^0.183.0",
|
||||
"@sparkjsdev/spark": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dfb230fc1f942f259dd00281a1186953ad602fc5d69067ce63e24b2aa439736b
|
||||
size 2160468
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4b1b7471e55d88a4aaa6235dc8f404acc2fdd83f1927e3b41ca96a6517744f47
|
||||
size 4784380
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 MiB |
Binary file not shown.
@@ -0,0 +1,58 @@
|
||||
export const GAME = {
|
||||
FOV: 65,
|
||||
NEAR: 0.01,
|
||||
FAR: 1000,
|
||||
MAX_DELTA: 0.05,
|
||||
MAX_DPR: 2,
|
||||
};
|
||||
|
||||
export const IS_MOBILE = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
|
||||
(navigator.maxTouchPoints > 1);
|
||||
|
||||
// Collider bounds: ~11.4 x 1.7 x 6.2 units, centered near (-1.4, 0, 2.0)
|
||||
// Tighter play area to stay in the good-looking splat region
|
||||
export const PLAYER = {
|
||||
SIZE: 1,
|
||||
SPEED: 1.0,
|
||||
START_X: -1.4,
|
||||
START_Y: -0.86,
|
||||
START_Z: 2.0,
|
||||
COLOR: 0x44aaff,
|
||||
TURN_SPEED: 8, // radians/s for model rotation
|
||||
};
|
||||
|
||||
export const CAMERA = {
|
||||
EYE_HEIGHT: 0.9,
|
||||
MOUSE_SENSITIVITY: 0.002,
|
||||
FOLLOW_DISTANCE: 1.5, // distance behind player
|
||||
FOLLOW_HEIGHT: 0.8, // height above player feet
|
||||
LOOK_HEIGHT: 0.45, // look-at height on player (chest level)
|
||||
LERP_SPEED: 10, // camera smoothing
|
||||
};
|
||||
|
||||
export const BOUNDS = {
|
||||
MIN_X: -5.5,
|
||||
MAX_X: 3.0,
|
||||
MIN_Z: 0.0,
|
||||
MAX_Z: 4.2,
|
||||
};
|
||||
|
||||
export const COLORS = {
|
||||
SKY: 0x1a0a2e,
|
||||
};
|
||||
|
||||
export const WORLD = {
|
||||
splatPath: 'assets/worlds/arcade.spz', // full-res (30MB) for sharp close-up
|
||||
colliderPath: 'assets/worlds/arcade-collider.glb',
|
||||
panoPath: 'assets/worlds/arcade-pano.png',
|
||||
scale: 1,
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
};
|
||||
|
||||
export const CHARACTER = {
|
||||
path: 'assets/models/Soldier.glb',
|
||||
scale: 0.5, // GLB has internal 0.01 scale (cm export) → 0.5 × 0.01 = ~0.9m tall
|
||||
offsetY: 0,
|
||||
facingOffset: Math.PI,
|
||||
clipMap: { idle: 'Idle', walk: 'Walk', run: 'Run' },
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
class EventBusImpl {
|
||||
constructor() { this._listeners = {}; }
|
||||
on(event, fn) { (this._listeners[event] ||= []).push(fn); }
|
||||
off(event, fn) { const arr = this._listeners[event]; if (arr) this._listeners[event] = arr.filter(f => f !== fn); }
|
||||
emit(event, data) { (this._listeners[event] || []).forEach(fn => fn(data)); }
|
||||
}
|
||||
|
||||
export const eventBus = new EventBusImpl();
|
||||
|
||||
export const Events = {
|
||||
GAME_RESTART: 'game:restart',
|
||||
GAME_OVER: 'game:over',
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import * as THREE from 'three';
|
||||
import { GAME, CAMERA, COLORS, PLAYER, BOUNDS } from './Constants.js';
|
||||
import { eventBus, Events } from './EventBus.js';
|
||||
import { gameState } from './GameState.js';
|
||||
import { InputSystem } from '../systems/InputSystem.js';
|
||||
import { loadWorld, getGroundHeight } from '../level/WorldLoader.js';
|
||||
import { Player } from '../gameplay/Player.js';
|
||||
|
||||
export class Game {
|
||||
constructor() {
|
||||
this.clock = new THREE.Clock();
|
||||
this.yaw = -Math.PI * 0.5; // face along +X into the arcade
|
||||
this.pitch = 0.0;
|
||||
this.player = null;
|
||||
|
||||
// Renderer — antialias OFF is critical for Gaussian Splats
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: false });
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, GAME.MAX_DPR));
|
||||
this.renderer.setClearColor(COLORS.SKY);
|
||||
document.body.prepend(this.renderer.domElement);
|
||||
|
||||
// Scene
|
||||
this.scene = new THREE.Scene();
|
||||
|
||||
// Camera
|
||||
this.camera = new THREE.PerspectiveCamera(
|
||||
GAME.FOV, window.innerWidth / window.innerHeight, GAME.NEAR, GAME.FAR
|
||||
);
|
||||
|
||||
// Systems
|
||||
this.input = new InputSystem();
|
||||
|
||||
// Lighting for mesh objects (character model)
|
||||
this.scene.add(new THREE.AmbientLight(0xffffff, 1.0));
|
||||
const dir = new THREE.DirectionalLight(0xffffee, 1.5);
|
||||
dir.position.set(2, 5, 3);
|
||||
this.scene.add(dir);
|
||||
|
||||
// Pointer lock for mouse look (orbits around player)
|
||||
this.renderer.domElement.addEventListener('click', () => {
|
||||
this.renderer.domElement.requestPointerLock();
|
||||
});
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (document.pointerLockElement !== this.renderer.domElement) return;
|
||||
this.yaw -= e.movementX * CAMERA.MOUSE_SENSITIVITY;
|
||||
this.pitch -= e.movementY * CAMERA.MOUSE_SENSITIVITY;
|
||||
this.pitch = Math.max(-0.5, Math.min(1.2, this.pitch));
|
||||
});
|
||||
|
||||
eventBus.on(Events.GAME_RESTART, () => this.restart());
|
||||
window.addEventListener('resize', () => this.onResize());
|
||||
|
||||
this.loadingEl = document.getElementById('loading');
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
if (this.loadingEl) this.loadingEl.textContent = 'Loading world...';
|
||||
await loadWorld(this.scene, this.renderer, this.camera);
|
||||
|
||||
// Create the player character
|
||||
this.player = new Player(this.scene);
|
||||
|
||||
if (this.loadingEl) this.loadingEl.classList.add('hidden');
|
||||
} catch (err) {
|
||||
console.error('[Game] World load error:', err);
|
||||
if (this.loadingEl) this.loadingEl.textContent = 'World load failed';
|
||||
}
|
||||
|
||||
this.startGame();
|
||||
this.renderer.setAnimationLoop(() => this.animate());
|
||||
}
|
||||
|
||||
startGame() {
|
||||
gameState.reset();
|
||||
gameState.started = true;
|
||||
if (this.player) this.player.reset();
|
||||
this.yaw = Math.PI * 0.5;
|
||||
this.pitch = 0.05;
|
||||
}
|
||||
|
||||
restart() { this.startGame(); }
|
||||
|
||||
animate() {
|
||||
const delta = Math.min(this.clock.getDelta(), GAME.MAX_DELTA);
|
||||
this.input.update();
|
||||
|
||||
if (gameState.started && !gameState.gameOver && this.player) {
|
||||
// Update player movement + animation (pass camera yaw as azimuth)
|
||||
this.player.update(delta, this.input, this.yaw);
|
||||
|
||||
// Pin player to starting Y (collider is unreliable after Y-flip)
|
||||
const pp = this.player.mesh.position;
|
||||
pp.y = PLAYER.START_Y;
|
||||
|
||||
// Clamp player to world bounds
|
||||
pp.x = Math.max(BOUNDS.MIN_X, Math.min(BOUNDS.MAX_X, pp.x));
|
||||
pp.z = Math.max(BOUNDS.MIN_Z, Math.min(BOUNDS.MAX_Z, pp.z));
|
||||
|
||||
// Third-person camera: orbit behind player
|
||||
const idealX = pp.x + Math.sin(this.yaw) * CAMERA.FOLLOW_DISTANCE;
|
||||
const idealZ = pp.z + Math.cos(this.yaw) * CAMERA.FOLLOW_DISTANCE;
|
||||
const idealY = pp.y + CAMERA.FOLLOW_HEIGHT + Math.sin(this.pitch) * CAMERA.FOLLOW_DISTANCE * 0.5;
|
||||
|
||||
// Smooth follow
|
||||
const t = 1 - Math.exp(-CAMERA.LERP_SPEED * delta);
|
||||
this.camera.position.x += (idealX - this.camera.position.x) * t;
|
||||
this.camera.position.y += (idealY - this.camera.position.y) * t;
|
||||
this.camera.position.z += (idealZ - this.camera.position.z) * t;
|
||||
|
||||
// Look at player
|
||||
this.camera.lookAt(pp.x, pp.y + CAMERA.LOOK_HEIGHT, pp.z);
|
||||
}
|
||||
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
onResize() {
|
||||
this.camera.aspect = window.innerWidth / window.innerHeight;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
class GameStateImpl {
|
||||
constructor() { this.reset(); }
|
||||
reset() {
|
||||
this.started = false;
|
||||
this.gameOver = false;
|
||||
this.score = 0;
|
||||
this.bestScore = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const gameState = new GameStateImpl();
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as THREE from 'three';
|
||||
import { PLAYER, CHARACTER } from '../core/Constants.js';
|
||||
import { loadAnimatedModel } from '../level/AssetLoader.js';
|
||||
|
||||
const _v = new THREE.Vector3();
|
||||
const _q = new THREE.Quaternion();
|
||||
const _up = new THREE.Vector3(0, 1, 0);
|
||||
|
||||
export class Player {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
this.mixer = null;
|
||||
this.actions = {};
|
||||
this.activeAction = null;
|
||||
this.model = null;
|
||||
this.ready = false;
|
||||
|
||||
// Group is the position anchor — camera follows this
|
||||
this.mesh = new THREE.Group();
|
||||
this.mesh.position.set(PLAYER.START_X, PLAYER.START_Y, PLAYER.START_Z);
|
||||
this.scene.add(this.mesh);
|
||||
|
||||
this._loadModel();
|
||||
}
|
||||
|
||||
async _loadModel() {
|
||||
try {
|
||||
const { model, clips } = await loadAnimatedModel(CHARACTER.path);
|
||||
model.scale.setScalar(CHARACTER.scale);
|
||||
model.position.y = CHARACTER.offsetY;
|
||||
|
||||
this.model = model;
|
||||
this.mesh.add(model);
|
||||
|
||||
// Set up mixer
|
||||
this.mixer = new THREE.AnimationMixer(model);
|
||||
for (const clip of clips) {
|
||||
this.actions[clip.name] = this.mixer.clipAction(clip);
|
||||
}
|
||||
|
||||
// Start idle
|
||||
const idleClip = CHARACTER.clipMap.idle;
|
||||
if (this.actions[idleClip]) {
|
||||
this.actions[idleClip].play();
|
||||
this.activeAction = this.actions[idleClip];
|
||||
}
|
||||
|
||||
this.ready = true;
|
||||
console.log('[Player] Loaded. Animations:', Object.keys(this.actions).join(', '));
|
||||
} catch (err) {
|
||||
console.warn('[Player] Model failed, using fallback:', err.message);
|
||||
// Fallback: colored box
|
||||
const geo = new THREE.BoxGeometry(0.3, 0.9, 0.3);
|
||||
const mat = new THREE.MeshLambertMaterial({ color: PLAYER.COLOR });
|
||||
const box = new THREE.Mesh(geo, mat);
|
||||
box.position.y = 0.45;
|
||||
this.mesh.add(box);
|
||||
this.ready = true;
|
||||
}
|
||||
}
|
||||
|
||||
fadeToAction(key, duration = 0.3) {
|
||||
const clipName = CHARACTER.clipMap[key];
|
||||
const next = this.actions[clipName];
|
||||
if (!next || next === this.activeAction) return;
|
||||
|
||||
if (this.activeAction) this.activeAction.fadeOut(duration);
|
||||
next.reset().setEffectiveTimeScale(1).setEffectiveWeight(1).fadeIn(duration).play();
|
||||
this.activeAction = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} delta
|
||||
* @param {InputSystem} input
|
||||
* @param {number} cameraYaw — yaw angle from Game (pointer lock)
|
||||
*/
|
||||
update(delta, input, cameraYaw) {
|
||||
if (this.mixer) this.mixer.update(delta);
|
||||
if (!this.ready) return;
|
||||
|
||||
let ix = 0, iz = 0;
|
||||
if (input.forward) iz -= 1;
|
||||
if (input.backward) iz += 1;
|
||||
if (input.left) ix -= 1;
|
||||
if (input.right) ix += 1;
|
||||
|
||||
const isMoving = ix !== 0 || iz !== 0;
|
||||
|
||||
if (isMoving) {
|
||||
// Camera-relative movement
|
||||
_v.set(ix, 0, iz).normalize();
|
||||
_v.applyAxisAngle(_up, cameraYaw);
|
||||
|
||||
const speed = input.shift ? PLAYER.SPEED * 2.5 : PLAYER.SPEED;
|
||||
this.mesh.position.addScaledVector(_v, speed * delta);
|
||||
|
||||
// Rotate model to face movement direction
|
||||
if (this.model) {
|
||||
const angle = Math.atan2(_v.x, _v.z) + (CHARACTER.facingOffset || 0);
|
||||
_q.setFromAxisAngle(_up, angle);
|
||||
this.model.quaternion.rotateTowards(_q, PLAYER.TURN_SPEED * delta);
|
||||
}
|
||||
|
||||
this.fadeToAction(input.shift ? 'run' : 'walk');
|
||||
} else {
|
||||
this.fadeToAction('idle');
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.mesh.position.set(PLAYER.START_X, PLAYER.START_Y, PLAYER.START_Z);
|
||||
if (this.model) {
|
||||
this.model.quaternion.identity();
|
||||
}
|
||||
this.fadeToAction('idle');
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.mixer) this.mixer.stopAllAction();
|
||||
this.mesh.traverse((c) => {
|
||||
if (c.isMesh) {
|
||||
c.geometry.dispose();
|
||||
if (Array.isArray(c.material)) c.material.forEach(m => m.dispose());
|
||||
else c.material.dispose();
|
||||
}
|
||||
});
|
||||
this.scene.remove(this.mesh);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
|
||||
import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
loader.setMeshoptDecoder(MeshoptDecoder);
|
||||
const cache = new Map();
|
||||
|
||||
/**
|
||||
* Load a GLTF/GLB model (static, no skeleton).
|
||||
*/
|
||||
export async function loadModel(path) {
|
||||
const gltf = await _load(path);
|
||||
const clone = gltf.scene.clone(true);
|
||||
|
||||
clone.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
child.material = child.material.clone();
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a GLTF/GLB model with skeleton + animations.
|
||||
* Uses SkeletonUtils.clone() so bone bindings survive cloning.
|
||||
*/
|
||||
export async function loadAnimatedModel(path) {
|
||||
const gltf = await _load(path);
|
||||
|
||||
// SkeletonUtils.clone properly re-binds SkinnedMesh to cloned Skeleton
|
||||
const model = SkeletonUtils.clone(gltf.scene);
|
||||
|
||||
model.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
|
||||
return { model, clips: gltf.animations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload multiple paths in parallel. Returns when all are cached.
|
||||
* @param {string[]} paths
|
||||
* @param {(loaded: number, total: number) => void} [onProgress]
|
||||
*/
|
||||
export async function preloadAll(paths, onProgress) {
|
||||
let loaded = 0;
|
||||
const total = paths.length;
|
||||
await Promise.all(paths.map((path) =>
|
||||
_load(path).then(() => {
|
||||
loaded++;
|
||||
if (onProgress) onProgress(loaded, total);
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose all cached models.
|
||||
*/
|
||||
export function disposeAll() {
|
||||
cache.forEach((promise) => {
|
||||
promise.then((gltf) => {
|
||||
gltf.scene.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
child.geometry.dispose();
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach((m) => m.dispose());
|
||||
} else {
|
||||
child.material.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
function _load(path) {
|
||||
if (!cache.has(path)) {
|
||||
cache.set(path, new Promise((resolve, reject) => {
|
||||
loader.load(path, resolve, undefined,
|
||||
(err) => reject(new Error(`Failed to load: ${path} — ${err.message || err}`))
|
||||
);
|
||||
}));
|
||||
}
|
||||
return cache.get(path);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
import { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
|
||||
import { WORLD } from '../core/Constants.js';
|
||||
|
||||
let _colliderMesh = null;
|
||||
let _splatMesh = null;
|
||||
|
||||
/**
|
||||
* Load the World Labs environment.
|
||||
*/
|
||||
export async function loadWorld(scene, renderer, camera) {
|
||||
// SparkRenderer — quality tuning for splat rendering
|
||||
const spark = new SparkRenderer({
|
||||
renderer,
|
||||
preBlurAmount: 0.1, // Minimal blur — full-res splat is sharp enough
|
||||
maxPixelRadius: 512, // Allow larger splats for close-up detail
|
||||
});
|
||||
scene.add(spark);
|
||||
|
||||
const promises = [];
|
||||
|
||||
if (WORLD.splatPath) {
|
||||
promises.push(loadSplat(scene).catch(err => {
|
||||
console.warn('[WorldLoader] Splat failed:', err.message);
|
||||
}));
|
||||
}
|
||||
|
||||
if (WORLD.colliderPath) {
|
||||
promises.push(loadCollider(scene).catch(err => {
|
||||
console.warn('[WorldLoader] Collider failed:', err.message);
|
||||
}));
|
||||
}
|
||||
|
||||
// Panorama disabled — same scene as splat causes "world inside world" doubling
|
||||
// if (WORLD.panoPath) { ... }
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
console.log('[WorldLoader] Environment loaded',
|
||||
_splatMesh ? '(splat OK)' : '(no splat)',
|
||||
_colliderMesh ? '(collider OK)' : '(no collider)'
|
||||
);
|
||||
|
||||
return { splat: _splatMesh, collider: _colliderMesh };
|
||||
}
|
||||
|
||||
async function loadSplat(scene) {
|
||||
const splat = new SplatMesh({ url: WORLD.splatPath });
|
||||
splat.scale.setScalar(WORLD.scale);
|
||||
// World Labs SPZ is Y-flipped. rotation.x=PI flips Y and Z.
|
||||
// Compensate Z shift: world Z range [-1.08, 5.16] → offset = sum = 4.08
|
||||
splat.rotation.x = Math.PI;
|
||||
splat.position.set(WORLD.position.x, WORLD.position.y, WORLD.position.z + 4.08);
|
||||
scene.add(splat);
|
||||
if (splat.initialized) await splat.initialized;
|
||||
_splatMesh = splat;
|
||||
}
|
||||
|
||||
async function loadCollider(scene) {
|
||||
const loader = new GLTFLoader();
|
||||
const gltf = await loader.loadAsync(WORLD.colliderPath);
|
||||
_colliderMesh = gltf.scene;
|
||||
_colliderMesh.visible = false;
|
||||
_colliderMesh.scale.setScalar(WORLD.scale);
|
||||
// Match splat Y-flip: rotation.x=PI + Z compensation
|
||||
_colliderMesh.rotation.x = Math.PI;
|
||||
_colliderMesh.position.set(WORLD.position.x, WORLD.position.y, WORLD.position.z + 4.08);
|
||||
_colliderMesh.traverse(child => {
|
||||
if (child.isMesh) child.material.side = THREE.DoubleSide;
|
||||
});
|
||||
// Force matrix update so raycasts work before first render
|
||||
_colliderMesh.updateMatrixWorld(true);
|
||||
scene.add(_colliderMesh);
|
||||
}
|
||||
|
||||
async function loadPanorama(scene) {
|
||||
const texLoader = new THREE.TextureLoader();
|
||||
const panoTex = await texLoader.loadAsync(WORLD.panoPath);
|
||||
panoTex.mapping = THREE.EquirectangularReflectionMapping;
|
||||
panoTex.colorSpace = THREE.SRGBColorSpace;
|
||||
scene.background = panoTex;
|
||||
scene.environment = panoTex;
|
||||
}
|
||||
|
||||
const _raycaster = new THREE.Raycaster();
|
||||
const _upDir = new THREE.Vector3(0, 1, 0);
|
||||
const _rayOrigin = new THREE.Vector3();
|
||||
|
||||
let _lastGroundY = 0;
|
||||
|
||||
export function getGroundHeight(x, z, fallback = 0) {
|
||||
if (!_colliderMesh) return fallback;
|
||||
// Raycast UPWARD from below — after Y-flip, the visual floor is the
|
||||
// lowest surface. Shooting up from below hits the floor first.
|
||||
_rayOrigin.set(x, -50, z);
|
||||
_raycaster.set(_rayOrigin, _upDir);
|
||||
const hits = _raycaster.intersectObject(_colliderMesh, true);
|
||||
if (hits.length > 0) {
|
||||
_lastGroundY = hits[0].point.y;
|
||||
return _lastGroundY;
|
||||
}
|
||||
// No hit — keep last known ground height to prevent floating
|
||||
return _lastGroundY;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Game } from './core/Game.js';
|
||||
import { gameState } from './core/GameState.js';
|
||||
|
||||
const game = new Game();
|
||||
|
||||
// Expose for testing
|
||||
window.__GAME__ = game;
|
||||
window.__GAME_STATE__ = gameState;
|
||||
|
||||
window.render_game_to_text = () => {
|
||||
if (!game || !gameState) return JSON.stringify({ error: 'not_ready' });
|
||||
|
||||
const payload = {
|
||||
coords: 'origin:center x:right y:up z:toward-camera',
|
||||
mode: gameState.gameOver ? 'game_over' : gameState.started ? 'playing' : 'loading',
|
||||
score: gameState.score,
|
||||
};
|
||||
|
||||
if (gameState.started && game.player) {
|
||||
const pos = game.player.mesh.position;
|
||||
payload.player = {
|
||||
x: Math.round(pos.x * 100) / 100,
|
||||
y: Math.round(pos.y * 100) / 100,
|
||||
z: Math.round(pos.z * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
return JSON.stringify(payload);
|
||||
};
|
||||
|
||||
window.advanceTime = (ms) => {
|
||||
return new Promise((resolve) => {
|
||||
const start = performance.now();
|
||||
function step() {
|
||||
if (performance.now() - start >= ms) return resolve();
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// =============================================================================
|
||||
// InputSystem.js — Keyboard state tracker for third-person controller
|
||||
//
|
||||
// WASD / Arrow keys for movement. Provides both boolean (forward/left/etc)
|
||||
// and analog (moveX/moveZ) accessors for backward compatibility.
|
||||
// =============================================================================
|
||||
|
||||
export class InputSystem {
|
||||
constructor() {
|
||||
this.keys = {};
|
||||
window.addEventListener('keydown', (e) => {
|
||||
this.keys[e.code] = true;
|
||||
if (e.code.startsWith('Arrow')) e.preventDefault();
|
||||
});
|
||||
window.addEventListener('keyup', (e) => { this.keys[e.code] = false; });
|
||||
}
|
||||
|
||||
isDown(code) { return !!this.keys[code]; }
|
||||
setGameActive() {}
|
||||
update() {}
|
||||
|
||||
get forward() { return this.isDown('KeyW') || this.isDown('ArrowUp'); }
|
||||
get backward() { return this.isDown('KeyS') || this.isDown('ArrowDown'); }
|
||||
get left() { return this.isDown('KeyA') || this.isDown('ArrowLeft'); }
|
||||
get right() { return this.isDown('KeyD') || this.isDown('ArrowRight'); }
|
||||
get shift() { return this.isDown('ShiftLeft') || this.isDown('ShiftRight'); }
|
||||
get jump() { return this.isDown('Space'); }
|
||||
|
||||
get moveX() { return (this.right ? 1 : 0) - (this.left ? 1 : 0); }
|
||||
get moveZ() { return (this.backward ? 1 : 0) - (this.forward ? 1 : 0); }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { eventBus, Events } from '../core/EventBus.js';
|
||||
import { gameState } from '../core/GameState.js';
|
||||
|
||||
export class Menu {
|
||||
constructor() {
|
||||
this.gameoverOverlay = document.getElementById('gameover-overlay');
|
||||
this.restartBtn = document.getElementById('restart-btn');
|
||||
this.finalScoreEl = document.getElementById('final-score');
|
||||
this.bestScoreEl = document.getElementById('best-score');
|
||||
|
||||
this.restartBtn.addEventListener('click', () => {
|
||||
this.gameoverOverlay.classList.add('hidden');
|
||||
eventBus.emit(Events.GAME_RESTART);
|
||||
});
|
||||
|
||||
eventBus.on(Events.GAME_OVER, ({ score }) => this.showGameOver(score));
|
||||
}
|
||||
|
||||
showGameOver(score) {
|
||||
this.finalScoreEl.textContent = `Score: ${score}`;
|
||||
this.bestScoreEl.textContent = `Best: ${gameState.bestScore}`;
|
||||
this.gameoverOverlay.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user